Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bae5340f0 | |||
| 0b322817d7 | |||
| e41f335c63 | |||
| 0ab33af5d3 | |||
| e04c9381f3 | |||
| ceb9cc0b44 | |||
| 4cecaba83a | |||
| 4053f1f989 | |||
| ca67b1bd29 | |||
| 199095ee43 | |||
| eb493d501a | |||
| 6c98732117 | |||
| a2e9fd4d14 | |||
| d228b6b812 | |||
| c634aecd4b | |||
| 14b7c1cf32 | |||
| 402af91af5 | |||
| 9a6b656e3c | |||
| d2f9597b0c | |||
| c40dcf0e25 | |||
| 28653e3a9f | |||
| 7c3246c6e7 | |||
| bd48f57ce0 | |||
| a6ec21accc | |||
| 320cf40f41 | |||
| 3beca10429 | |||
| 8bea5dac6c | |||
| 12f8bbf8f5 | |||
| d15790b948 | |||
| d80f76e386 | |||
| 07bce8699a | |||
| 9b84508ff4 | |||
| 6fdf0ad3c5 | |||
| 4f7bfcc3ad | |||
| a3ae61d993 | |||
| 838a247ef9 | |||
| a5e5e78c4e | |||
| c386e462b2 | |||
| 2c0457d503 | |||
| b03b3eb4d9 | |||
| 29c2872819 | |||
| 3e96c62f33 | |||
| 6880dee489 | |||
| 0e45c13515 | |||
| 54def611fa | |||
| b1d094104a | |||
| 9d66b78a11 | |||
| eddbbca7ef | |||
| 4e7b5304a0 | |||
| 06485053fc | |||
| 92a57d33df | |||
| 6adc5b0967 | |||
| d215d02844 | |||
| 57863e16e9 |
@@ -41,7 +41,6 @@ static class NestConsole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
using var log = SetUpLog(options);
|
|
||||||
var nest = LoadOrCreateNest(options);
|
var nest = LoadOrCreateNest(options);
|
||||||
|
|
||||||
if (nest == null)
|
if (nest == null)
|
||||||
@@ -68,10 +67,6 @@ static class NestConsole
|
|||||||
|
|
||||||
var overlapCount = CheckOverlaps(plate, options);
|
var overlapCount = CheckOverlaps(plate, options);
|
||||||
|
|
||||||
// Flush and close the log before printing results.
|
|
||||||
Trace.Flush();
|
|
||||||
log?.Dispose();
|
|
||||||
|
|
||||||
PrintResults(success, plate, elapsed);
|
PrintResults(success, plate, elapsed);
|
||||||
Save(nest, options);
|
Save(nest, options);
|
||||||
PostProcess(nest, options);
|
PostProcess(nest, options);
|
||||||
@@ -112,9 +107,6 @@ static class NestConsole
|
|||||||
case "--no-save":
|
case "--no-save":
|
||||||
o.NoSave = true;
|
o.NoSave = true;
|
||||||
break;
|
break;
|
||||||
case "--no-log":
|
|
||||||
o.NoLog = true;
|
|
||||||
break;
|
|
||||||
case "--keep-parts":
|
case "--keep-parts":
|
||||||
o.KeepParts = true;
|
o.KeepParts = true;
|
||||||
break;
|
break;
|
||||||
@@ -153,21 +145,6 @@ static class NestConsole
|
|||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
static StreamWriter SetUpLog(Options options)
|
|
||||||
{
|
|
||||||
if (options.NoLog)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var baseDir = Path.GetDirectoryName(options.InputFiles[0]);
|
|
||||||
var logDir = Path.Combine(baseDir, "test-harness-logs");
|
|
||||||
Directory.CreateDirectory(logDir);
|
|
||||||
var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log");
|
|
||||||
var writer = new StreamWriter(logFile) { AutoFlush = true };
|
|
||||||
Trace.Listeners.Add(new TextWriterTraceListener(writer));
|
|
||||||
Console.WriteLine($"Debug log: {logFile}");
|
|
||||||
return writer;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Nest LoadOrCreateNest(Options options)
|
static Nest LoadOrCreateNest(Options options)
|
||||||
{
|
{
|
||||||
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
||||||
@@ -503,7 +480,6 @@ static class NestConsole
|
|||||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||||
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||||
Console.Error.WriteLine(" --no-save Skip saving output file");
|
Console.Error.WriteLine(" --no-save Skip saving output file");
|
||||||
Console.Error.WriteLine(" --no-log Skip writing debug log file");
|
|
||||||
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
|
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
|
||||||
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
|
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
|
||||||
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
|
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
|
||||||
@@ -522,7 +498,6 @@ static class NestConsole
|
|||||||
public Size? PlateSize;
|
public Size? PlateSize;
|
||||||
public bool CheckOverlaps;
|
public bool CheckOverlaps;
|
||||||
public bool NoSave;
|
public bool NoSave;
|
||||||
public bool NoLog;
|
|
||||||
public bool KeepParts;
|
public bool KeepParts;
|
||||||
public bool AutoNest;
|
public bool AutoNest;
|
||||||
public string TemplateFile;
|
public string TemplateFile;
|
||||||
|
|||||||
@@ -69,8 +69,16 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
EmitScribeContours(result, scribeEntities);
|
EmitScribeContours(result, scribeEntities);
|
||||||
|
|
||||||
foreach (var entry in cutoutEntries)
|
foreach (var entry in cutoutEntries)
|
||||||
|
{
|
||||||
|
if (!entry.Shape.IsClosed())
|
||||||
|
EmitRawContour(result, entry.Shape);
|
||||||
|
else
|
||||||
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile.Perimeter.IsClosed())
|
||||||
|
EmitRawContour(result, profile.Perimeter);
|
||||||
|
else
|
||||||
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
||||||
|
|
||||||
result.Mode = Mode.Incremental;
|
result.Mode = Mode.Incremental;
|
||||||
@@ -99,10 +107,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
// Find the target shape that contains the clicked entity
|
// Find the target shape that contains the clicked entity
|
||||||
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
|
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
|
||||||
|
|
||||||
// Emit cutouts — only the target gets lead-in/out
|
// Emit cutouts — only the target gets lead-in/out (skip open contours)
|
||||||
foreach (var cutout in profile.Cutouts)
|
foreach (var cutout in profile.Cutouts)
|
||||||
{
|
{
|
||||||
if (cutout == targetShape)
|
if (!cutout.IsClosed())
|
||||||
|
{
|
||||||
|
EmitRawContour(result, cutout);
|
||||||
|
}
|
||||||
|
else if (cutout == targetShape)
|
||||||
{
|
{
|
||||||
var ct = DetectContourType(cutout);
|
var ct = DetectContourType(cutout);
|
||||||
EmitContour(result, cutout, point, matchedEntity, ct);
|
EmitContour(result, cutout, point, matchedEntity, ct);
|
||||||
@@ -114,7 +126,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Emit perimeter
|
// Emit perimeter
|
||||||
if (profile.Perimeter == targetShape)
|
if (!profile.Perimeter.IsClosed())
|
||||||
|
{
|
||||||
|
EmitRawContour(result, profile.Perimeter);
|
||||||
|
}
|
||||||
|
else if (profile.Perimeter == targetShape)
|
||||||
{
|
{
|
||||||
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
|
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
|
||||||
}
|
}
|
||||||
@@ -289,9 +305,6 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
subPgm.Codes.AddRange(leadIn.Generate(relativePoint, normal, winding));
|
subPgm.Codes.AddRange(leadIn.Generate(relativePoint, normal, winding));
|
||||||
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
|
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
|
||||||
|
|
||||||
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
|
|
||||||
reindexed = TrimShapeForTab(reindexed, relativePoint, Parameters.TabConfig.Size);
|
|
||||||
|
|
||||||
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
|
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
|
||||||
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
|
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
|
||||||
subPgm.Mode = Mode.Incremental;
|
subPgm.Mode = Mode.Incremental;
|
||||||
@@ -315,7 +328,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
var reindexedShape = shape.ReindexAt(point, entity);
|
var reindexedShape = shape.ReindexAt(point, entity);
|
||||||
|
|
||||||
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
|
if (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External)
|
||||||
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||||
|
|
||||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC
|
||||||
|
{
|
||||||
|
public static class RapidEnumerator
|
||||||
|
{
|
||||||
|
public readonly record struct Segment(Vector From, Vector To);
|
||||||
|
|
||||||
|
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
|
||||||
|
{
|
||||||
|
var results = new List<Segment>();
|
||||||
|
|
||||||
|
// Draw the rapid from the previous tool position to the program's first
|
||||||
|
// pierce point. This also primes pos so the interior walk interprets
|
||||||
|
// Incremental deltas from the correct absolute location (basePos), which
|
||||||
|
// matters for raw pre-lead-in programs that are emitted Incremental.
|
||||||
|
var firstPierce = FirstPiercePoint(pgm, basePos);
|
||||||
|
results.Add(new Segment(startPos, firstPierce));
|
||||||
|
|
||||||
|
var pos = firstPierce;
|
||||||
|
Walk(pgm, basePos, ref pos, skipFirst: true, results);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < pgm.Length; i++)
|
||||||
|
{
|
||||||
|
if (pgm[i] is SubProgramCall call && call.Program != null)
|
||||||
|
return FirstPiercePoint(call.Program, basePos + call.Offset);
|
||||||
|
|
||||||
|
if (pgm[i] is Motion motion)
|
||||||
|
return motion.EndPoint + basePos;
|
||||||
|
}
|
||||||
|
return basePos;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Walk(Program pgm, Vector basePos, ref Vector pos, bool skipFirst, List<Segment> results)
|
||||||
|
{
|
||||||
|
var skipped = !skipFirst;
|
||||||
|
|
||||||
|
for (var i = 0; i < pgm.Length; ++i)
|
||||||
|
{
|
||||||
|
var code = pgm[i];
|
||||||
|
|
||||||
|
if (code is SubProgramCall { Program: { } program } call)
|
||||||
|
{
|
||||||
|
var holeBase = basePos + call.Offset;
|
||||||
|
var firstPierce = FirstPiercePoint(program, holeBase);
|
||||||
|
|
||||||
|
if (!skipped)
|
||||||
|
skipped = true;
|
||||||
|
else
|
||||||
|
results.Add(new Segment(pos, firstPierce));
|
||||||
|
|
||||||
|
var subPos = holeBase;
|
||||||
|
Walk(program, holeBase, ref subPos, skipFirst: true, results);
|
||||||
|
pos = subPos;
|
||||||
|
}
|
||||||
|
else if (code is Motion motion)
|
||||||
|
{
|
||||||
|
var endpt = pgm.Mode == Mode.Incremental
|
||||||
|
? motion.EndPoint + pos
|
||||||
|
: motion.EndPoint + basePos;
|
||||||
|
|
||||||
|
if (code.Type == CodeType.RapidMove)
|
||||||
|
{
|
||||||
|
if (!skipped)
|
||||||
|
skipped = true;
|
||||||
|
else
|
||||||
|
results.Add(new Segment(pos, endpt));
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = endpt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the rotation that maps a drawing to its canonical (MBR-axis-aligned) frame.
|
||||||
|
/// Lives in OpenNest.Core so Drawing.Program setter can invoke it directly without
|
||||||
|
/// a circular dependency on OpenNest.Engine.
|
||||||
|
/// </summary>
|
||||||
|
public static class CanonicalAngle
|
||||||
|
{
|
||||||
|
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
|
||||||
|
public const double SnapToZero = 0.001;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
|
||||||
|
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula
|
||||||
|
/// across both callers.
|
||||||
|
/// </summary>
|
||||||
|
public static double FromMbr(BoundingRectangleResult mbr)
|
||||||
|
{
|
||||||
|
if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
// The MBR edge angle can represent any of four equivalent orientations
|
||||||
|
// (edge-i, edge-i + π/2, edge-i + π, edge-i - π/2) depending on which hull
|
||||||
|
// edge the algorithm happened to pick. Normalize -mbr.Angle to the
|
||||||
|
// representative in [-π/4, π/4] so snap-to-zero works for inputs near
|
||||||
|
// ANY of the equivalent orientations.
|
||||||
|
var angle = -mbr.Angle;
|
||||||
|
const double halfPi = System.Math.PI / 2.0;
|
||||||
|
angle -= halfPi * System.Math.Round(angle / halfPi);
|
||||||
|
|
||||||
|
if (System.Math.Abs(angle) < SnapToZero)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
return angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double Compute(Drawing drawing)
|
||||||
|
{
|
||||||
|
if (drawing?.Program == null)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||||
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
|
if (shapes.Count == 0)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var perimeter = shapes[0];
|
||||||
|
var perimeterArea = perimeter.Area();
|
||||||
|
for (var i = 1; i < shapes.Count; i++)
|
||||||
|
{
|
||||||
|
var area = shapes[i].Area();
|
||||||
|
if (area > perimeterArea)
|
||||||
|
{
|
||||||
|
perimeter = shapes[i];
|
||||||
|
perimeterArea = area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var polygon = perimeter.ToPolygonWithTolerance(0.1);
|
||||||
|
if (polygon == null || polygon.Vertices.Count < 3)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var hull = ConvexHull.Compute(polygon.Vertices);
|
||||||
|
if (hull.Vertices.Count < 3)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
|
||||||
|
return FromMbr(mbr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace OpenNest.Converters
|
namespace OpenNest.Converters
|
||||||
@@ -81,12 +82,21 @@ namespace OpenNest.Converters
|
|||||||
var startpt = arc.StartPoint();
|
var startpt = arc.StartPoint();
|
||||||
var endpt = arc.EndPoint();
|
var endpt = arc.EndPoint();
|
||||||
|
|
||||||
if (startpt != lastpt)
|
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(startpt);
|
pgm.MoveTo(startpt);
|
||||||
|
|
||||||
lastpt = endpt;
|
lastpt = endpt;
|
||||||
|
|
||||||
|
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||||
|
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||||
|
{
|
||||||
|
pgm.LineTo(endpt);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
||||||
|
}
|
||||||
|
|
||||||
return lastpt;
|
return lastpt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +104,7 @@ namespace OpenNest.Converters
|
|||||||
{
|
{
|
||||||
var startpt = new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
var startpt = new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
||||||
|
|
||||||
if (startpt != lastpt)
|
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(startpt);
|
pgm.MoveTo(startpt);
|
||||||
|
|
||||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
||||||
@@ -105,7 +115,7 @@ namespace OpenNest.Converters
|
|||||||
|
|
||||||
private static Vector AddLine(Program pgm, Vector lastpt, Line line)
|
private static Vector AddLine(Program pgm, Vector lastpt, Line line)
|
||||||
{
|
{
|
||||||
if (line.StartPoint != lastpt)
|
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(line.StartPoint);
|
pgm.MoveTo(line.StartPoint);
|
||||||
|
|
||||||
var move = new LinearMove(line.EndPoint);
|
var move = new LinearMove(line.EndPoint);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace OpenNest
|
|||||||
private static int nextColorIndex;
|
private static int nextColorIndex;
|
||||||
private Program program;
|
private Program program;
|
||||||
|
|
||||||
public static readonly Color[] PartColors = new Color[]
|
public static Color[] PartColors = new Color[]
|
||||||
{
|
{
|
||||||
Color.FromArgb(205, 92, 92), // Indian Red
|
Color.FromArgb(205, 92, 92), // Indian Red
|
||||||
Color.FromArgb(148, 103, 189), // Medium Purple
|
Color.FromArgb(148, 103, 189), // Medium Purple
|
||||||
@@ -54,9 +54,9 @@ namespace OpenNest
|
|||||||
Id = Interlocked.Increment(ref nextId);
|
Id = Interlocked.Increment(ref nextId);
|
||||||
Name = name;
|
Name = name;
|
||||||
Material = new Material();
|
Material = new Material();
|
||||||
Program = pgm;
|
|
||||||
Constraints = new NestConstraints();
|
Constraints = new NestConstraints();
|
||||||
Source = new SourceInfo();
|
Source = new SourceInfo();
|
||||||
|
Program = pgm;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Id { get; }
|
public int Id { get; }
|
||||||
@@ -78,9 +78,29 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
program = value;
|
program = value;
|
||||||
UpdateArea();
|
UpdateArea();
|
||||||
|
RecomputeCanonicalAngle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recomputes and stores the canonical angle from the current Program.
|
||||||
|
/// Callers that mutate Program in place (rather than reassigning it) must invoke this explicitly.
|
||||||
|
/// Cut-off drawings are left with Angle=0.
|
||||||
|
/// </summary>
|
||||||
|
public void RecomputeCanonicalAngle()
|
||||||
|
{
|
||||||
|
if (Source == null)
|
||||||
|
Source = new SourceInfo();
|
||||||
|
|
||||||
|
if (program == null || IsCutOff)
|
||||||
|
{
|
||||||
|
Source.Angle = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Source.Angle = CanonicalAngle.Compute(this);
|
||||||
|
}
|
||||||
|
|
||||||
public Color Color { get; set; }
|
public Color Color { get; set; }
|
||||||
|
|
||||||
public bool IsCutOff { get; set; }
|
public bool IsCutOff { get; set; }
|
||||||
@@ -163,5 +183,15 @@ namespace OpenNest
|
|||||||
/// Offset distances to the original location.
|
/// Offset distances to the original location.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Vector Offset { get; set; }
|
public Vector Offset { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rotation (radians) that maps the source program geometry to its canonical
|
||||||
|
/// (MBR-axis-aligned) frame. Populated automatically by the <see cref="Drawing.Program"/>
|
||||||
|
/// setter via <see cref="CanonicalAngle.Compute"/>. A value of 0 means the drawing is
|
||||||
|
/// already canonical or <see cref="Drawing.IsCutOff"/> is true. Callers that mutate
|
||||||
|
/// <see cref="Drawing.Program"/> in place must invoke
|
||||||
|
/// <see cref="Drawing.RecomputeCanonicalAngle"/> to refresh.
|
||||||
|
/// </summary>
|
||||||
|
public double Angle { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,13 @@ namespace OpenNest.Geometry
|
|||||||
get { return Diameter * System.Math.PI * SweepAngle() / Angle.TwoPI; }
|
get { return Diameter * System.Math.PI * SweepAngle() / Angle.TwoPI; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Arc(center, radius, startAngle, endAngle, reversed);
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction.
|
/// Reverses the rotation direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -397,10 +404,12 @@ namespace OpenNest.Geometry
|
|||||||
maxY = startpt.Y;
|
maxY = startpt.Y;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sweep = SweepAngle();
|
||||||
|
if (sweep > Tolerance.Epsilon)
|
||||||
|
{
|
||||||
var angle1 = StartAngle;
|
var angle1 = StartAngle;
|
||||||
var angle2 = EndAngle;
|
var angle2 = EndAngle;
|
||||||
|
|
||||||
// switch the angle to counter clockwise.
|
|
||||||
if (IsReversed)
|
if (IsReversed)
|
||||||
Generic.Swap(ref angle1, ref angle2);
|
Generic.Swap(ref angle1, ref angle2);
|
||||||
|
|
||||||
@@ -417,6 +426,7 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
|
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
|
||||||
maxX = Center.X + Radius;
|
maxX = Center.X + Radius;
|
||||||
|
}
|
||||||
|
|
||||||
boundingBox.X = minX;
|
boundingBox.X = minX;
|
||||||
boundingBox.Y = minY;
|
boundingBox.Y = minY;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
using OpenNest.Math;
|
using System;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
{
|
{
|
||||||
public class Box
|
public class Box : IComparable<Box>
|
||||||
{
|
{
|
||||||
public static readonly Box Empty = new Box();
|
public static readonly Box Empty = new Box();
|
||||||
|
|
||||||
@@ -214,5 +215,19 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length);
|
return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int CompareTo(Box other)
|
||||||
|
{
|
||||||
|
var cmp = Width.CompareTo(other.Width);
|
||||||
|
return cmp != 0 ? cmp : Length.CompareTo(other.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator >(Box a, Box b) => a.CompareTo(b) > 0;
|
||||||
|
|
||||||
|
public static bool operator <(Box a, Box b) => a.CompareTo(b) < 0;
|
||||||
|
|
||||||
|
public static bool operator >=(Box a, Box b) => a.CompareTo(b) >= 0;
|
||||||
|
|
||||||
|
public static bool operator <=(Box a, Box b) => a.CompareTo(b) <= 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,13 @@ namespace OpenNest.Geometry
|
|||||||
get { return Circumference(); }
|
get { return Circumference(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Circle(center, radius) { Rotation = Rotation };
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction.
|
/// Reverses the rotation direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -173,7 +173,11 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
if (maxDev <= tolerance)
|
if (maxDev <= tolerance)
|
||||||
{
|
{
|
||||||
results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1));
|
var arc = CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1);
|
||||||
|
if (arc.SweepAngle() < Tolerance.Epsilon)
|
||||||
|
results.Add(new Line(p0, p1));
|
||||||
|
else
|
||||||
|
results.Add(arc);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -251,6 +251,23 @@ namespace OpenNest.Geometry
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public abstract bool Intersects(Shape shape, out List<Vector> pts);
|
public abstract bool Intersects(Shape shape, out List<Vector> pts);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a deep copy of the entity with a new Id.
|
||||||
|
/// </summary>
|
||||||
|
public abstract Entity Clone();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies common Entity properties from this instance to the target.
|
||||||
|
/// </summary>
|
||||||
|
protected void CopyBaseTo(Entity target)
|
||||||
|
{
|
||||||
|
target.Color = Color;
|
||||||
|
target.Layer = Layer;
|
||||||
|
target.LineTypeName = LineTypeName;
|
||||||
|
target.IsVisible = IsVisible;
|
||||||
|
target.Tag = Tag;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Type of entity.
|
/// Type of entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -259,6 +276,14 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
public static class EntityExtensions
|
public static class EntityExtensions
|
||||||
{
|
{
|
||||||
|
public static List<Entity> CloneAll(this IEnumerable<Entity> entities)
|
||||||
|
{
|
||||||
|
var result = new List<Entity>();
|
||||||
|
foreach (var e in entities)
|
||||||
|
result.Add(e.Clone());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public static List<Vector> CollectPoints(this IEnumerable<Entity> entities)
|
public static List<Vector> CollectPoints(this IEnumerable<Entity> entities)
|
||||||
{
|
{
|
||||||
var points = new List<Vector>();
|
var points = new List<Vector>();
|
||||||
|
|||||||
@@ -257,6 +257,13 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Line(pt1, pt2);
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reversed the line.
|
/// Reversed the line.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ namespace OpenNest.Geometry
|
|||||||
get { return Perimeter(); }
|
get { return Perimeter(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Polygon { Vertices = new List<Vector>(Vertices) };
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction of the polygon.
|
/// Reverses the rotation direction of the polygon.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -349,6 +349,15 @@ namespace OpenNest.Geometry
|
|||||||
return polygon;
|
return polygon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Shape();
|
||||||
|
foreach (var e in Entities)
|
||||||
|
copy.Entities.Add(e.Clone());
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction of the shape.
|
/// Reverses the rotation direction of the shape.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
{
|
{
|
||||||
public static class ShapeBuilder
|
public static class ShapeBuilder
|
||||||
{
|
{
|
||||||
public static List<Shape> GetShapes(IEnumerable<Entity> entities)
|
public static List<Shape> GetShapes(IEnumerable<Entity> entities, double? weldTolerance = null)
|
||||||
{
|
{
|
||||||
var lines = new List<Line>();
|
var lines = new List<Line>();
|
||||||
var arcs = new List<Arc>();
|
var arcs = new List<Arc>();
|
||||||
@@ -57,6 +58,9 @@ namespace OpenNest.Geometry
|
|||||||
entityList.AddRange(lines);
|
entityList.AddRange(lines);
|
||||||
entityList.AddRange(arcs);
|
entityList.AddRange(arcs);
|
||||||
|
|
||||||
|
if (weldTolerance.HasValue)
|
||||||
|
WeldEndpoints(entityList, weldTolerance.Value);
|
||||||
|
|
||||||
while (entityList.Count > 0)
|
while (entityList.Count > 0)
|
||||||
{
|
{
|
||||||
var next = entityList[0];
|
var next = entityList[0];
|
||||||
@@ -107,6 +111,93 @@ namespace OpenNest.Geometry
|
|||||||
return shapes;
|
return shapes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void WeldEndpoints(List<Entity> entities, double tolerance)
|
||||||
|
{
|
||||||
|
var endpointGroups = new List<List<(Entity entity, bool isStart, Vector point)>>();
|
||||||
|
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
var (start, end) = GetEndpoints(entity);
|
||||||
|
if (!start.IsValid() || !end.IsValid())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
AddToGroup(endpointGroups, entity, true, start, tolerance);
|
||||||
|
AddToGroup(endpointGroups, entity, false, end, tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in endpointGroups)
|
||||||
|
{
|
||||||
|
if (group.Count <= 1)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var avgX = group.Average(g => g.point.X);
|
||||||
|
var avgY = group.Average(g => g.point.Y);
|
||||||
|
var weldedPoint = new Vector(avgX, avgY);
|
||||||
|
|
||||||
|
foreach (var (entity, isStart, _) in group)
|
||||||
|
ApplyWeld(entity, isStart, weldedPoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddToGroup(
|
||||||
|
List<List<(Entity entity, bool isStart, Vector point)>> groups,
|
||||||
|
Entity entity, bool isStart, Vector point, double tolerance)
|
||||||
|
{
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
if (group[0].point.DistanceTo(point) <= tolerance)
|
||||||
|
{
|
||||||
|
group.Add((entity, isStart, point));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.Add(new List<(Entity, bool, Vector)> { (entity, isStart, point) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Vector start, Vector end) GetEndpoints(Entity entity)
|
||||||
|
{
|
||||||
|
switch (entity.Type)
|
||||||
|
{
|
||||||
|
case EntityType.Arc:
|
||||||
|
var arc = (Arc)entity;
|
||||||
|
return (arc.StartPoint(), arc.EndPoint());
|
||||||
|
|
||||||
|
case EntityType.Line:
|
||||||
|
var line = (Line)entity;
|
||||||
|
return (line.StartPoint, line.EndPoint);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (Vector.Invalid, Vector.Invalid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyWeld(Entity entity, bool isStart, Vector weldedPoint)
|
||||||
|
{
|
||||||
|
switch (entity.Type)
|
||||||
|
{
|
||||||
|
case EntityType.Line:
|
||||||
|
var line = (Line)entity;
|
||||||
|
if (isStart)
|
||||||
|
line.StartPoint = weldedPoint;
|
||||||
|
else
|
||||||
|
line.EndPoint = weldedPoint;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EntityType.Arc:
|
||||||
|
var arc = (Arc)entity;
|
||||||
|
var deltaX = weldedPoint.X - arc.Center.X;
|
||||||
|
var deltaY = weldedPoint.Y - arc.Center.Y;
|
||||||
|
var angle = System.Math.Atan2(deltaY, deltaX);
|
||||||
|
|
||||||
|
if (isStart)
|
||||||
|
arc.StartAngle = angle;
|
||||||
|
else
|
||||||
|
arc.EndAngle = angle;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
|
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
|
||||||
{
|
{
|
||||||
var tol = Tolerance.ChainTolerance;
|
var tol = Tolerance.ChainTolerance;
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ namespace OpenNest.Geometry
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
|
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
|
||||||
{
|
{
|
||||||
var profile = new ShapeProfile(entities.ToList());
|
var cloned = entities.CloneAll();
|
||||||
|
var profile = new ShapeProfile(cloned);
|
||||||
return profile.ToNormalizedEntities();
|
return profile.ToNormalizedEntities();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,50 +306,39 @@ namespace OpenNest.Geometry
|
|||||||
var minDist = double.MaxValue;
|
var minDist = double.MaxValue;
|
||||||
var vx = vertex.X;
|
var vx = vertex.X;
|
||||||
var vy = vertex.Y;
|
var vy = vertex.Y;
|
||||||
|
var horizontal = IsHorizontalDirection(direction);
|
||||||
|
|
||||||
// Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary.
|
// Pruning: edges are sorted by their perpendicular min-coordinate.
|
||||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
// For horizontal push, prune by Y range; for vertical push, prune by X range.
|
||||||
{
|
|
||||||
for (var i = 0; i < edges.Length; i++)
|
for (var i = 0; i < edges.Length; i++)
|
||||||
{
|
{
|
||||||
var e1 = edges[i].start + edgeOffset;
|
var e1 = edges[i].start + edgeOffset;
|
||||||
var e2 = edges[i].end + edgeOffset;
|
var e2 = edges[i].end + edgeOffset;
|
||||||
|
|
||||||
var minY = e1.Y < e2.Y ? e1.Y : e2.Y;
|
double perpValue, edgeMin, edgeMax;
|
||||||
var maxY = e1.Y > e2.Y ? e1.Y : e2.Y;
|
if (horizontal)
|
||||||
|
{
|
||||||
|
perpValue = vy;
|
||||||
|
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
|
||||||
|
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
perpValue = vx;
|
||||||
|
edgeMin = e1.X < e2.X ? e1.X : e2.X;
|
||||||
|
edgeMax = e1.X > e2.X ? e1.X : e2.X;
|
||||||
|
}
|
||||||
|
|
||||||
// Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY.
|
// Since edges are sorted by edgeMin, if perpValue < edgeMin, all subsequent edges are also past.
|
||||||
if (vy < minY - Tolerance.Epsilon)
|
if (perpValue < edgeMin - Tolerance.Epsilon)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
if (vy > maxY + Tolerance.Epsilon)
|
if (perpValue > edgeMax + Tolerance.Epsilon)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
||||||
if (d < minDist) minDist = d;
|
if (d < minDist) minDist = d;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else // Up/Down
|
|
||||||
{
|
|
||||||
for (var i = 0; i < edges.Length; i++)
|
|
||||||
{
|
|
||||||
var e1 = edges[i].start + edgeOffset;
|
|
||||||
var e2 = edges[i].end + edgeOffset;
|
|
||||||
|
|
||||||
var minX = e1.X < e2.X ? e1.X : e2.X;
|
|
||||||
var maxX = e1.X > e2.X ? e1.X : e2.X;
|
|
||||||
|
|
||||||
// Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX.
|
|
||||||
if (vx < minX - Tolerance.Epsilon)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (vx > maxX + Tolerance.Epsilon)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
|
||||||
if (d < minDist) minDist = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return minDist;
|
return minDist;
|
||||||
}
|
}
|
||||||
@@ -642,22 +631,49 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
for (var i = 0; i < arcEntities.Count; i++)
|
for (var i = 0; i < arcEntities.Count; i++)
|
||||||
{
|
{
|
||||||
if (arcEntities[i] is Arc arc)
|
if (arcEntities[i] is not Arc arc)
|
||||||
{
|
continue;
|
||||||
|
|
||||||
|
var cx = arc.Center.X;
|
||||||
|
var cy = arc.Center.Y;
|
||||||
|
var r = arc.Radius;
|
||||||
|
|
||||||
for (var j = 0; j < lineEntities.Count; j++)
|
for (var j = 0; j < lineEntities.Count; j++)
|
||||||
{
|
{
|
||||||
if (lineEntities[j] is Line line)
|
if (lineEntities[j] is not Line line)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var p1x = line.pt1.X;
|
||||||
|
var p1y = line.pt1.Y;
|
||||||
|
var ex = line.pt2.X - p1x;
|
||||||
|
var ey = line.pt2.Y - p1y;
|
||||||
|
|
||||||
|
var det = ex * dirY - ey * dirX;
|
||||||
|
if (System.Math.Abs(det) < Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// The directional distance from an arc point at angle θ to the
|
||||||
|
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
|
||||||
|
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
|
||||||
|
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
|
||||||
|
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
|
||||||
|
|
||||||
|
for (var k = 0; k < 2; k++)
|
||||||
{
|
{
|
||||||
var linePt = line.ClosestPointTo(arc.Center);
|
var theta = k == 0 ? theta1 : theta2;
|
||||||
var arcPt = arc.ClosestPointTo(linePt);
|
|
||||||
var d = RayEdgeDistance(arcPt.X, arcPt.Y,
|
if (!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||||
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
|
continue;
|
||||||
|
|
||||||
|
var qx = cx + r * System.Math.Cos(theta);
|
||||||
|
var qy = cy + r * System.Math.Sin(theta);
|
||||||
|
|
||||||
|
var d = RayEdgeDistance(qx, qy, p1x, p1y, line.pt2.X, line.pt2.Y,
|
||||||
dirX, dirY);
|
dirX, dirY);
|
||||||
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
|
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return minDist;
|
return minDist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using System.Linq;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace OpenNest.IO.Bom
|
namespace OpenNest.Math
|
||||||
{
|
{
|
||||||
public static class Fraction
|
public static class Fraction
|
||||||
{
|
{
|
||||||
@@ -126,20 +126,10 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
var result = new List<Entity>(source.Count);
|
var result = new List<Entity>(source.Count);
|
||||||
|
|
||||||
for (var i = 0; i < source.Count; i++)
|
foreach (var entity in source)
|
||||||
{
|
{
|
||||||
var entity = source[i];
|
var copy = entity.Clone();
|
||||||
Entity copy;
|
copy.Offset(location);
|
||||||
|
|
||||||
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);
|
result.Add(copy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using OpenNest.Collections;
|
using OpenNest.Collections;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
using OpenNest.Shapes;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -548,6 +549,65 @@ namespace OpenNest
|
|||||||
Rounding.RoundUpToNearest(xExtent, roundingFactor));
|
Rounding.RoundUpToNearest(xExtent, roundingFactor));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sizes the plate using the <see cref="PlateSizes"/> catalog: small
|
||||||
|
/// layouts snap to an increment, larger ones round up to the next
|
||||||
|
/// standard mill sheet. The plate's long-axis orientation (X vs Y)
|
||||||
|
/// is preserved. Does nothing if the plate has no parts.
|
||||||
|
/// </summary>
|
||||||
|
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
if (Parts.Count == 0)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
var bounds = Parts.GetBoundingBox();
|
||||||
|
|
||||||
|
// Quadrant-aware extents relative to the plate origin, matching AutoSize.
|
||||||
|
double xExtent;
|
||||||
|
double yExtent;
|
||||||
|
|
||||||
|
switch (Quadrant)
|
||||||
|
{
|
||||||
|
case 1:
|
||||||
|
xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right;
|
||||||
|
yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left;
|
||||||
|
yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left;
|
||||||
|
yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right;
|
||||||
|
yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlateSizes.Recommend takes (short, long); canonicalize then map
|
||||||
|
// the result back so the plate's long axis stays aligned with the
|
||||||
|
// parts' long axis.
|
||||||
|
var shortDim = System.Math.Min(xExtent, yExtent);
|
||||||
|
var longDim = System.Math.Max(xExtent, yExtent);
|
||||||
|
var result = PlateSizes.Recommend(shortDim, longDim, options);
|
||||||
|
|
||||||
|
// Plate convention: Length = X axis, Width = Y axis.
|
||||||
|
if (xExtent >= yExtent)
|
||||||
|
Size = new Size(result.Width, result.Length); // X is the long axis
|
||||||
|
else
|
||||||
|
Size = new Size(result.Length, result.Width); // Y is the long axis
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the area of the top surface of the plate.
|
/// Gets the area of the top surface of the plate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ namespace OpenNest.Shapes
|
|||||||
{
|
{
|
||||||
public double Diameter { get; set; }
|
public double Diameter { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Circle {Dim(Diameter)} Dia";
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
Diameter = 8;
|
Diameter = 8;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
|
|||||||
public double Base { get; set; }
|
public double Base { get; set; }
|
||||||
public double Height { get; set; }
|
public double Height { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Isosceles Triangle {Dim(Base)}x{Dim(Height)}";
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
Base = 8;
|
Base = 8;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
|
|||||||
public double LegWidth { get; set; }
|
public double LegWidth { get; set; }
|
||||||
public double LegHeight { get; set; }
|
public double LegHeight { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"L {Dim(Width)}x{Dim(Height)}";
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
Width = 8;
|
Width = 8;
|
||||||
|
|||||||
@@ -3,33 +3,40 @@ using System.Collections.Generic;
|
|||||||
|
|
||||||
namespace OpenNest.Shapes
|
namespace OpenNest.Shapes
|
||||||
{
|
{
|
||||||
public class OctagonShape : ShapeDefinition
|
public class NgonShape : ShapeDefinition
|
||||||
{
|
{
|
||||||
|
public int Sides { get; set; }
|
||||||
public double Width { get; set; }
|
public double Width { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"{Sides}-Sided Polygon {Dim(Width)}";
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
|
Sides = 8;
|
||||||
Width = 8;
|
Width = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
|
var n = Sides < 3 ? 3 : Sides;
|
||||||
var center = Width / 2.0;
|
var center = Width / 2.0;
|
||||||
var circumRadius = Width / (2.0 * System.Math.Cos(System.Math.PI / 8.0));
|
var circumRadius = Width / (2.0 * System.Math.Cos(System.Math.PI / n));
|
||||||
|
var step = 2.0 * System.Math.PI / n;
|
||||||
|
var start = System.Math.PI / n;
|
||||||
|
|
||||||
var vertices = new Vector[8];
|
var vertices = new Vector[n];
|
||||||
for (var i = 0; i < 8; i++)
|
for (var i = 0; i < n; i++)
|
||||||
{
|
{
|
||||||
var angle = System.Math.PI / 8.0 + i * System.Math.PI / 4.0;
|
var angle = start + i * step;
|
||||||
vertices[i] = new Vector(
|
vertices[i] = new Vector(
|
||||||
center + circumRadius * System.Math.Cos(angle),
|
center + circumRadius * System.Math.Cos(angle),
|
||||||
center + circumRadius * System.Math.Sin(angle));
|
center + circumRadius * System.Math.Sin(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
for (var i = 0; i < 8; i++)
|
for (var i = 0; i < n; i++)
|
||||||
{
|
{
|
||||||
var next = (i + 1) % 8;
|
var next = (i + 1) % n;
|
||||||
entities.Add(new Line(vertices[i], vertices[next]));
|
entities.Add(new Line(vertices[i], vertices[next]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,31 +3,41 @@ using System.Collections.Generic;
|
|||||||
|
|
||||||
namespace OpenNest.Shapes
|
namespace OpenNest.Shapes
|
||||||
{
|
{
|
||||||
public class FlangeShape : ShapeDefinition
|
public class PipeFlangeShape : ShapeDefinition
|
||||||
{
|
{
|
||||||
public double NominalPipeSize { get; set; }
|
|
||||||
public double OD { get; set; }
|
public double OD { get; set; }
|
||||||
public double HoleDiameter { get; set; }
|
public double HoleDiameter { get; set; }
|
||||||
public double HolePatternDiameter { get; set; }
|
public double HolePatternDiameter { get; set; }
|
||||||
public int HoleCount { get; set; }
|
public int HoleCount { get; set; }
|
||||||
|
public string PipeSize { get; set; }
|
||||||
|
public double PipeClearance { get; set; }
|
||||||
|
public bool Blind { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName()
|
||||||
|
{
|
||||||
|
var name = $"Pipe Flange {Dim(OD)} OD";
|
||||||
|
if (!string.IsNullOrEmpty(PipeSize))
|
||||||
|
name += $" {PipeSize} Pipe";
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
NominalPipeSize = 2;
|
|
||||||
OD = 7.5;
|
OD = 7.5;
|
||||||
HoleDiameter = 0.875;
|
HoleDiameter = 0.875;
|
||||||
HolePatternDiameter = 5.5;
|
HolePatternDiameter = 5.5;
|
||||||
HoleCount = 8;
|
HoleCount = 8;
|
||||||
|
PipeSize = "2";
|
||||||
|
PipeClearance = 0.0625;
|
||||||
|
Blind = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
|
|
||||||
// Outer circle
|
|
||||||
entities.Add(new Circle(0, 0, OD / 2.0));
|
entities.Add(new Circle(0, 0, OD / 2.0));
|
||||||
|
|
||||||
// Bolt holes evenly spaced on the bolt circle
|
|
||||||
var boltCircleRadius = HolePatternDiameter / 2.0;
|
var boltCircleRadius = HolePatternDiameter / 2.0;
|
||||||
var holeRadius = HoleDiameter / 2.0;
|
var holeRadius = HoleDiameter / 2.0;
|
||||||
var angleStep = 2.0 * System.Math.PI / HoleCount;
|
var angleStep = 2.0 * System.Math.PI / HoleCount;
|
||||||
@@ -40,6 +50,12 @@ namespace OpenNest.Shapes
|
|||||||
entities.Add(new Circle(cx, cy, holeRadius));
|
entities.Add(new Circle(cx, cy, holeRadius));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Blind && !string.IsNullOrEmpty(PipeSize) && PipeSizes.TryGetOD(PipeSize, out var pipeOD))
|
||||||
|
{
|
||||||
|
var boreDiameter = pipeOD + PipeClearance;
|
||||||
|
entities.Add(new Circle(0, 0, boreDiameter / 2.0));
|
||||||
|
}
|
||||||
|
|
||||||
return CreateDrawing(entities);
|
return CreateDrawing(entities);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Shapes
|
||||||
|
{
|
||||||
|
public static class PipeSizes
|
||||||
|
{
|
||||||
|
public readonly record struct Entry(string Label, double OuterDiameter);
|
||||||
|
|
||||||
|
public static IReadOnlyList<Entry> All { get; } = new[]
|
||||||
|
{
|
||||||
|
new Entry("1/8", 0.405),
|
||||||
|
new Entry("1/4", 0.540),
|
||||||
|
new Entry("3/8", 0.675),
|
||||||
|
new Entry("1/2", 0.840),
|
||||||
|
new Entry("3/4", 1.050),
|
||||||
|
new Entry("1", 1.315),
|
||||||
|
new Entry("1 1/4", 1.660),
|
||||||
|
new Entry("1 1/2", 1.900),
|
||||||
|
new Entry("2", 2.375),
|
||||||
|
new Entry("2 1/2", 2.875),
|
||||||
|
new Entry("3", 3.500),
|
||||||
|
new Entry("3 1/2", 4.000),
|
||||||
|
new Entry("4", 4.500),
|
||||||
|
new Entry("4 1/2", 5.000),
|
||||||
|
new Entry("5", 5.563),
|
||||||
|
new Entry("6", 6.625),
|
||||||
|
new Entry("7", 7.625),
|
||||||
|
new Entry("8", 8.625),
|
||||||
|
new Entry("9", 9.625),
|
||||||
|
new Entry("10", 10.750),
|
||||||
|
new Entry("11", 11.750),
|
||||||
|
new Entry("12", 12.750),
|
||||||
|
new Entry("14", 14.000),
|
||||||
|
new Entry("16", 16.000),
|
||||||
|
new Entry("18", 18.000),
|
||||||
|
new Entry("20", 20.000),
|
||||||
|
new Entry("24", 24.000),
|
||||||
|
new Entry("26", 26.000),
|
||||||
|
new Entry("28", 28.000),
|
||||||
|
new Entry("30", 30.000),
|
||||||
|
new Entry("32", 32.000),
|
||||||
|
new Entry("34", 34.000),
|
||||||
|
new Entry("36", 36.000),
|
||||||
|
new Entry("42", 42.000),
|
||||||
|
new Entry("48", 48.000),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool TryGetOD(string label, out double outerDiameter)
|
||||||
|
{
|
||||||
|
foreach (var entry in All)
|
||||||
|
{
|
||||||
|
if (entry.Label == label)
|
||||||
|
{
|
||||||
|
outerDiameter = entry.OuterDiameter;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outerDiameter = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all pipe sizes whose outer diameter is less than or equal to <paramref name="maxOD"/>.
|
||||||
|
/// The bound is inclusive.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Entry> GetFittingSizes(double maxOD)
|
||||||
|
{
|
||||||
|
foreach (var entry in All)
|
||||||
|
{
|
||||||
|
if (entry.OuterDiameter <= maxOD)
|
||||||
|
{
|
||||||
|
yield return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Shapes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Catalog of standard mill sheet sizes (inches) with helpers for matching
|
||||||
|
/// a bounding box to a recommended plate size. Uses the project-wide
|
||||||
|
/// (Width, Length) convention where Width is the short dimension and
|
||||||
|
/// Length is the long dimension.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlateSizes
|
||||||
|
{
|
||||||
|
public readonly record struct Entry(string Label, double Width, double Length)
|
||||||
|
{
|
||||||
|
public double Area => Width * Length;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if a part of the given dimensions fits within this entry
|
||||||
|
/// in either orientation.
|
||||||
|
/// </summary>
|
||||||
|
public bool Fits(double width, double length) =>
|
||||||
|
(width <= Width && length <= Length) || (width <= Length && length <= Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard mill sheet sizes (inches), sorted by area ascending.
|
||||||
|
/// Canonical orientation: Width <= Length.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<Entry> All { get; } = new[]
|
||||||
|
{
|
||||||
|
new Entry("48x96", 48, 96), // 4608
|
||||||
|
new Entry("48x120", 48, 120), // 5760
|
||||||
|
new Entry("48x144", 48, 144), // 6912
|
||||||
|
new Entry("60x120", 60, 120), // 7200
|
||||||
|
new Entry("60x144", 60, 144), // 8640
|
||||||
|
new Entry("72x120", 72, 120), // 8640
|
||||||
|
new Entry("72x144", 72, 144), // 10368
|
||||||
|
new Entry("96x240", 96, 240), // 23040
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a standard size by label. Case-insensitive.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGet(string label, out Entry entry)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(label))
|
||||||
|
{
|
||||||
|
foreach (var candidate in All)
|
||||||
|
{
|
||||||
|
if (string.Equals(candidate.Label, label, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
entry = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recommends a plate size for the given bounding box. The box's
|
||||||
|
/// spatial axes are normalized to (short, long) so neither the bbox
|
||||||
|
/// orientation nor Box's internal Length/Width naming matters.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateSizeResult Recommend(Box bbox, PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
var a = bbox.Width;
|
||||||
|
var b = bbox.Length;
|
||||||
|
return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recommends a plate size for the envelope of the given boxes.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateSizeResult Recommend(IEnumerable<Box> boxes, PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
if (boxes == null)
|
||||||
|
throw new ArgumentNullException(nameof(boxes));
|
||||||
|
|
||||||
|
var hasAny = false;
|
||||||
|
var minX = double.PositiveInfinity;
|
||||||
|
var minY = double.PositiveInfinity;
|
||||||
|
var maxX = double.NegativeInfinity;
|
||||||
|
var maxY = double.NegativeInfinity;
|
||||||
|
|
||||||
|
foreach (var box in boxes)
|
||||||
|
{
|
||||||
|
hasAny = true;
|
||||||
|
if (box.Left < minX) minX = box.Left;
|
||||||
|
if (box.Bottom < minY) minY = box.Bottom;
|
||||||
|
if (box.Right > maxX) maxX = box.Right;
|
||||||
|
if (box.Top > maxY) maxY = box.Top;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAny)
|
||||||
|
throw new ArgumentException("At least one box is required.", nameof(boxes));
|
||||||
|
|
||||||
|
var b = maxX - minX;
|
||||||
|
var a = maxY - minY;
|
||||||
|
return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recommends a plate size for a (width, length) pair.
|
||||||
|
/// Inputs are treated as orientation-independent.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateSizeResult Recommend(double width, double length, PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
options ??= new PlateSizeOptions();
|
||||||
|
|
||||||
|
var w = width + 2 * options.Margin;
|
||||||
|
var l = length + 2 * options.Margin;
|
||||||
|
|
||||||
|
// Canonicalize (short, long) — Fits handles rotation anyway, but
|
||||||
|
// normalizing lets the below-min comparison use the narrower
|
||||||
|
// MinSheet dimensions consistently.
|
||||||
|
if (w > l)
|
||||||
|
(w, l) = (l, w);
|
||||||
|
|
||||||
|
// Below full-sheet threshold: snap each dimension up to the nearest increment.
|
||||||
|
if (w <= options.MinSheetWidth && l <= options.MinSheetLength)
|
||||||
|
return SnapResult(w, l, options.SnapIncrement);
|
||||||
|
|
||||||
|
var catalog = BuildCatalog(options.AllowedSizes);
|
||||||
|
|
||||||
|
var best = PickBest(catalog, w, l, options.Selection);
|
||||||
|
if (best.HasValue)
|
||||||
|
return new PlateSizeResult(best.Value.Width, best.Value.Length, best.Value.Label);
|
||||||
|
|
||||||
|
// Nothing in the catalog fits - fall back to snap-up (ad-hoc oversize sheet).
|
||||||
|
return SnapResult(w, l, options.SnapIncrement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlateSizeResult SnapResult(double width, double length, double increment)
|
||||||
|
{
|
||||||
|
if (increment <= 0)
|
||||||
|
return new PlateSizeResult(width, length, null);
|
||||||
|
|
||||||
|
return new PlateSizeResult(SnapUp(width, increment), SnapUp(length, increment), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double SnapUp(double value, double increment)
|
||||||
|
{
|
||||||
|
var steps = System.Math.Ceiling(value / increment);
|
||||||
|
return steps * increment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<Entry> BuildCatalog(IReadOnlyList<string> allowedSizes)
|
||||||
|
{
|
||||||
|
if (allowedSizes == null || allowedSizes.Count == 0)
|
||||||
|
return All;
|
||||||
|
|
||||||
|
var result = new List<Entry>(allowedSizes.Count);
|
||||||
|
foreach (var label in allowedSizes)
|
||||||
|
{
|
||||||
|
if (TryParseEntry(label, out var entry))
|
||||||
|
result.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseEntry(string label, out Entry entry)
|
||||||
|
{
|
||||||
|
if (TryGet(label, out entry))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Accept ad-hoc "WxL" strings (e.g. "50x100", "50 x 100").
|
||||||
|
if (!string.IsNullOrWhiteSpace(label))
|
||||||
|
{
|
||||||
|
var parts = label.Split(new[] { 'x', 'X' }, 2);
|
||||||
|
if (parts.Length == 2
|
||||||
|
&& double.TryParse(parts[0].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var a)
|
||||||
|
&& double.TryParse(parts[1].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var b)
|
||||||
|
&& a > 0 && b > 0)
|
||||||
|
{
|
||||||
|
var width = System.Math.Min(a, b);
|
||||||
|
var length = System.Math.Max(a, b);
|
||||||
|
entry = new Entry(label.Trim(), width, length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entry? PickBest(IReadOnlyList<Entry> catalog, double width, double length, PlateSizeSelection selection)
|
||||||
|
{
|
||||||
|
var fitting = catalog.Where(e => e.Fits(width, length));
|
||||||
|
|
||||||
|
fitting = selection switch
|
||||||
|
{
|
||||||
|
PlateSizeSelection.NarrowestFirst => fitting.OrderBy(e => e.Width).ThenBy(e => e.Area),
|
||||||
|
_ => fitting.OrderBy(e => e.Area).ThenBy(e => e.Width),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var candidate in fitting)
|
||||||
|
return candidate;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct PlateSizeResult(double Width, double Length, string MatchedLabel)
|
||||||
|
{
|
||||||
|
public bool IsStandard => MatchedLabel != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlateSizeOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// If the margin-adjusted bounding box fits within MinSheetWidth x MinSheetLength
|
||||||
|
/// the result is snapped to <see cref="SnapIncrement"/> instead of routed to a
|
||||||
|
/// standard sheet. Default 48" x 48".
|
||||||
|
/// </summary>
|
||||||
|
public double MinSheetWidth { get; set; } = 48;
|
||||||
|
public double MinSheetLength { get; set; } = 48;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Increment used for below-threshold rounding and oversize fallback. Default 1".
|
||||||
|
/// </summary>
|
||||||
|
public double SnapIncrement { get; set; } = 1.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extra clearance added to each side of the bounding box before matching.
|
||||||
|
/// </summary>
|
||||||
|
public double Margin { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional whitelist. When non-empty, only these sizes are considered.
|
||||||
|
/// Entries may be standard catalog labels (e.g. "48x96") or arbitrary
|
||||||
|
/// "WxL" strings (e.g. "50x100").
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> AllowedSizes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiebreaker when multiple sheets can contain the bounding box.
|
||||||
|
/// </summary>
|
||||||
|
public PlateSizeSelection Selection { get; set; } = PlateSizeSelection.SmallestArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PlateSizeSelection
|
||||||
|
{
|
||||||
|
/// <summary>Pick the cheapest sheet that contains the bbox (smallest area).</summary>
|
||||||
|
SmallestArea,
|
||||||
|
/// <summary>Prefer narrower-width sheets (e.g. 48-wide before 60-wide).</summary>
|
||||||
|
NarrowestFirst,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
|
|||||||
public double Length { get; set; }
|
public double Length { get; set; }
|
||||||
public double Width { get; set; }
|
public double Width { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Rectangle {Dim(Length)}x{Dim(Width)}";
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
Length = 12;
|
Length = 12;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
|
|||||||
public double Width { get; set; }
|
public double Width { get; set; }
|
||||||
public double Height { get; set; }
|
public double Height { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Right Triangle {Dim(Width)}x{Dim(Height)}";
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
Width = 8;
|
Width = 8;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
|
|||||||
public double OuterDiameter { get; set; }
|
public double OuterDiameter { get; set; }
|
||||||
public double InnerDiameter { get; set; }
|
public double InnerDiameter { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Ring {Dim(OuterDiameter)}x{Dim(InnerDiameter)}";
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
OuterDiameter = 10;
|
OuterDiameter = 10;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
|
|||||||
public double Width { get; set; }
|
public double Width { get; set; }
|
||||||
public double Radius { get; set; }
|
public double Radius { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Rounded Rectangle {Dim(Length)}x{Dim(Width)} R{Dim(Radius)}";
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
Length = 12;
|
Length = 12;
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ namespace OpenNest.Shapes
|
|||||||
|
|
||||||
public abstract Drawing GetDrawing();
|
public abstract Drawing GetDrawing();
|
||||||
|
|
||||||
|
public virtual string GenerateName()
|
||||||
|
{
|
||||||
|
var typeName = GetType().Name;
|
||||||
|
return typeName.EndsWith("Shape")
|
||||||
|
? typeName.Substring(0, typeName.Length - 5)
|
||||||
|
: typeName;
|
||||||
|
}
|
||||||
|
|
||||||
public virtual void SetPreviewDefaults() { }
|
public virtual void SetPreviewDefaults() { }
|
||||||
|
|
||||||
public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
|
public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
|
||||||
@@ -34,6 +42,8 @@ namespace OpenNest.Shapes
|
|||||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions);
|
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static string Dim(double value) => value.ToString("0.###");
|
||||||
|
|
||||||
protected Drawing CreateDrawing(List<Entity> entities)
|
protected Drawing CreateDrawing(List<Entity> entities)
|
||||||
{
|
{
|
||||||
var pgm = ConvertGeometry.ToProgram(entities);
|
var pgm = ConvertGeometry.ToProgram(entities);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
|
|||||||
public double StemWidth { get; set; }
|
public double StemWidth { get; set; }
|
||||||
public double BarHeight { get; set; }
|
public double BarHeight { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"T {Dim(Width)}x{Dim(Height)}";
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
Width = 10;
|
Width = 10;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ namespace OpenNest.Shapes
|
|||||||
public double BottomWidth { get; set; }
|
public double BottomWidth { get; set; }
|
||||||
public double Height { get; set; }
|
public double Height { get; set; }
|
||||||
|
|
||||||
|
public override string GenerateName() => $"Trapezoid {Dim(TopWidth)}x{Dim(BottomWidth)}x{Dim(Height)}";
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
TopWidth = 6;
|
TopWidth = 6;
|
||||||
|
|||||||
@@ -32,12 +32,20 @@ public static class DrawingSplitter
|
|||||||
var regions = BuildClipRegions(sortedLines, bounds);
|
var regions = BuildClipRegions(sortedLines, bounds);
|
||||||
var feature = GetFeature(parameters.Type);
|
var feature = GetFeature(parameters.Type);
|
||||||
|
|
||||||
|
// Polygonize cutouts once. Used for trimming feature edges (so cut lines
|
||||||
|
// don't travel through a cutout interior) and for hole/containment tests
|
||||||
|
// in the final component-assembly pass.
|
||||||
|
var cutoutPolygons = profile.Cutouts
|
||||||
|
.Select(c => c.ToPolygon())
|
||||||
|
.Where(p => p != null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var results = new List<Drawing>();
|
var results = new List<Drawing>();
|
||||||
var pieceIndex = 1;
|
var pieceIndex = 1;
|
||||||
|
|
||||||
foreach (var region in regions)
|
foreach (var region in regions)
|
||||||
{
|
{
|
||||||
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters);
|
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters, cutoutPolygons);
|
||||||
if (pieceEntities.Count == 0)
|
if (pieceEntities.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -47,10 +55,17 @@ public static class DrawingSplitter
|
|||||||
allEntities.AddRange(pieceEntities);
|
allEntities.AddRange(pieceEntities);
|
||||||
allEntities.AddRange(cutoutEntities);
|
allEntities.AddRange(cutoutEntities);
|
||||||
|
|
||||||
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
|
// A single region may yield multiple physically-disjoint pieces when an
|
||||||
|
// interior cutout spans across it. Group the region's entities into
|
||||||
|
// connected closed loops, nest holes by containment, and emit one
|
||||||
|
// Drawing per outer loop (with its contained holes).
|
||||||
|
foreach (var pieceOfRegion in AssemblePieces(allEntities))
|
||||||
|
{
|
||||||
|
var piece = BuildPieceDrawing(drawing, pieceOfRegion, pieceIndex, region);
|
||||||
results.Add(piece);
|
results.Add(piece);
|
||||||
pieceIndex++;
|
pieceIndex++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
@@ -218,98 +233,106 @@ public static class DrawingSplitter
|
|||||||
/// and stitching in feature edges. No polygon clipping library needed.
|
/// and stitching in feature edges. No polygon clipping library needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
|
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
|
||||||
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters)
|
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters,
|
||||||
|
List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
|
||||||
|
|
||||||
foreach (var entity in perimeter.Entities)
|
foreach (var entity in perimeter.Entities)
|
||||||
{
|
ProcessEntity(entity, region, entities);
|
||||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entities.Count == 0)
|
if (entities.Count == 0)
|
||||||
return new List<Entity>();
|
return new List<Entity>();
|
||||||
|
|
||||||
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
|
InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons);
|
||||||
EnsurePerimeterWinding(entities);
|
// Winding is handled later in AssemblePieces, once connected components
|
||||||
|
// are known. At this stage the piece may still be multiple disjoint loops.
|
||||||
return entities;
|
return entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ProcessEntity(Entity entity, Box region,
|
private static void ProcessEntity(Entity entity, Box region, List<Entity> entities)
|
||||||
List<SplitLine> boundarySplitLines, List<Entity> entities,
|
|
||||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
|
||||||
{
|
|
||||||
// Find the first boundary split line this entity crosses
|
|
||||||
SplitLine crossedLine = null;
|
|
||||||
Vector? intersectionPt = null;
|
|
||||||
|
|
||||||
foreach (var sl in boundarySplitLines)
|
|
||||||
{
|
|
||||||
if (SplitLineIntersect.CrossesSplitLine(entity, sl))
|
|
||||||
{
|
|
||||||
var pt = SplitLineIntersect.FindIntersection(entity, sl);
|
|
||||||
if (pt != null)
|
|
||||||
{
|
|
||||||
crossedLine = sl;
|
|
||||||
intersectionPt = pt;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (crossedLine != null)
|
|
||||||
{
|
|
||||||
// Entity crosses a split line — split it and keep the half inside the region
|
|
||||||
var regionSide = RegionSideOf(region, crossedLine);
|
|
||||||
var startPt = GetStartPoint(entity);
|
|
||||||
var startSide = SplitLineIntersect.SideOf(startPt, crossedLine);
|
|
||||||
var startInRegion = startSide == regionSide || startSide == 0;
|
|
||||||
|
|
||||||
SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Entity doesn't cross any boundary split line — check if it's inside the region
|
|
||||||
var mid = MidPoint(entity);
|
|
||||||
if (region.Contains(mid))
|
|
||||||
entities.Add(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion,
|
|
||||||
SplitLine crossedLine, List<Entity> entities,
|
|
||||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
|
||||||
{
|
{
|
||||||
if (entity is Line line)
|
if (entity is Line line)
|
||||||
{
|
{
|
||||||
var (first, second) = line.SplitAt(point);
|
var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region);
|
||||||
if (startInRegion)
|
if (clipped == null) return;
|
||||||
|
if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return;
|
||||||
|
entities.Add(new Line(clipped.Value.Start, clipped.Value.End));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity is Arc arc)
|
||||||
{
|
{
|
||||||
if (first != null) entities.Add(first);
|
foreach (var sub in ClipArcToRegion(arc, region))
|
||||||
splitPoints.Add((point, crossedLine, true));
|
entities.Add(sub);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clips an arc against the four edges of a region box. Returns the sub-arcs
|
||||||
|
/// whose midpoints lie inside the region. Uses line-arc intersection to find
|
||||||
|
/// split points, then iteratively bisects the arc at each crossing.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Arc> ClipArcToRegion(Arc arc, Box region)
|
||||||
{
|
{
|
||||||
splitPoints.Add((point, crossedLine, false));
|
var edges = new[]
|
||||||
if (second != null) entities.Add(second);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (entity is Arc arc)
|
|
||||||
{
|
{
|
||||||
var (first, second) = arc.SplitAt(point);
|
new Line(new Vector(region.Left, region.Bottom), new Vector(region.Right, region.Bottom)),
|
||||||
if (startInRegion)
|
new Line(new Vector(region.Right, region.Bottom), new Vector(region.Right, region.Top)),
|
||||||
|
new Line(new Vector(region.Right, region.Top), new Vector(region.Left, region.Top)),
|
||||||
|
new Line(new Vector(region.Left, region.Top), new Vector(region.Left, region.Bottom))
|
||||||
|
};
|
||||||
|
|
||||||
|
var arcs = new List<Arc> { arc };
|
||||||
|
|
||||||
|
foreach (var edge in edges)
|
||||||
{
|
{
|
||||||
if (first != null) entities.Add(first);
|
var next = new List<Arc>();
|
||||||
splitPoints.Add((point, crossedLine, true));
|
foreach (var a in arcs)
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
splitPoints.Add((point, crossedLine, false));
|
if (!Intersect.Intersects(a, edge, out var pts) || pts.Count == 0)
|
||||||
if (second != null) entities.Add(second);
|
{
|
||||||
|
next.Add(a);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split the arc at each intersection that actually lies on one of
|
||||||
|
// the working sub-arcs. Prior splits may make some original hits
|
||||||
|
// moot for the sub-arc that now holds them.
|
||||||
|
var working = new List<Arc> { a };
|
||||||
|
foreach (var pt in pts)
|
||||||
|
{
|
||||||
|
var replaced = new List<Arc>();
|
||||||
|
foreach (var w in working)
|
||||||
|
{
|
||||||
|
var onArc = OpenNest.Math.Angle.IsBetweenRad(
|
||||||
|
w.Center.AngleTo(pt), w.StartAngle, w.EndAngle, w.IsReversed);
|
||||||
|
if (!onArc)
|
||||||
|
{
|
||||||
|
replaced.Add(w);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (first, second) = w.SplitAt(pt);
|
||||||
|
if (first != null && first.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(first);
|
||||||
|
if (second != null && second.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(second);
|
||||||
|
}
|
||||||
|
working = replaced;
|
||||||
|
}
|
||||||
|
next.AddRange(working);
|
||||||
|
}
|
||||||
|
arcs = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<Arc>();
|
||||||
|
foreach (var a in arcs)
|
||||||
|
{
|
||||||
|
if (region.Contains(a.MidPoint()))
|
||||||
|
result.Add(a);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -365,104 +388,157 @@ public static class DrawingSplitter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Groups split points by split line, pairs exits with entries, and generates feature edges.
|
/// For each boundary split line of the region, generates a feature edge that
|
||||||
|
/// spans the full region boundary along that split line and trims it against
|
||||||
|
/// interior cutouts. This produces one (or zero) feature edge per contiguous
|
||||||
|
/// material interval on the boundary, handling corner regions (one perimeter
|
||||||
|
/// crossing), spanning cutouts (two holes puncturing the line), and
|
||||||
|
/// normal mid-part splits uniformly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void InsertFeatureEdges(List<Entity> entities,
|
private static void InsertFeatureEdges(List<Entity> entities,
|
||||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints,
|
|
||||||
Box region, List<SplitLine> boundarySplitLines,
|
Box region, List<SplitLine> boundarySplitLines,
|
||||||
ISplitFeature feature, SplitParameters parameters)
|
ISplitFeature feature, SplitParameters parameters,
|
||||||
|
List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
// Group split points by their split line
|
foreach (var sl in boundarySplitLines)
|
||||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
|
||||||
foreach (var sp in splitPoints)
|
|
||||||
{
|
{
|
||||||
if (!groups.ContainsKey(sp.Line))
|
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||||
groups[sp.Line] = new List<(Vector, bool)>();
|
var extentStart = isVertical ? region.Bottom : region.Left;
|
||||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
var extentEnd = isVertical ? region.Top : region.Right;
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var kvp in groups)
|
if (extentEnd - extentStart < Math.Tolerance.Epsilon)
|
||||||
{
|
|
||||||
var sl = kvp.Key;
|
|
||||||
var points = kvp.Value;
|
|
||||||
|
|
||||||
// Pair each exit with the next entry
|
|
||||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList();
|
|
||||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList();
|
|
||||||
|
|
||||||
if (exits.Count == 0 || entries.Count == 0)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// For each exit, find the matching entry to form the feature edge span
|
|
||||||
// Sort exits and entries by their position along the split line
|
|
||||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
|
||||||
exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
|
||||||
entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
|
||||||
|
|
||||||
// Pair them up: each exit with the next entry (or vice versa)
|
|
||||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
|
||||||
for (var i = 0; i < pairCount; i++)
|
|
||||||
{
|
|
||||||
var exitPt = exits[i];
|
|
||||||
var entryPt = entries[i];
|
|
||||||
|
|
||||||
var extentStart = isVertical
|
|
||||||
? System.Math.Min(exitPt.Y, entryPt.Y)
|
|
||||||
: System.Math.Min(exitPt.X, entryPt.X);
|
|
||||||
var extentEnd = isVertical
|
|
||||||
? System.Math.Max(exitPt.Y, entryPt.Y)
|
|
||||||
: System.Math.Max(exitPt.X, entryPt.X);
|
|
||||||
|
|
||||||
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
||||||
|
|
||||||
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
||||||
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
||||||
|
|
||||||
if (featureEdge.Count > 0)
|
// Trim any line segments that cross a cutout — cut lines must never
|
||||||
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
|
// travel through a hole.
|
||||||
|
featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons);
|
||||||
|
|
||||||
entities.AddRange(featureEdge);
|
entities.AddRange(featureEdge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
|
/// <summary>
|
||||||
|
/// Subtracts any portions of line entities in <paramref name="featureEdge"/> that
|
||||||
|
/// lie inside any of the supplied cutout polygons. Non-line entities (arcs) are
|
||||||
|
/// passed through unchanged; a tighter fix for arcs in feature edges (weld-gap
|
||||||
|
/// tabs, spike-groove) can be added later if a test demands it.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Entity> TrimFeatureEdgeAgainstCutouts(List<Entity> featureEdge, List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
var featureStart = GetStartPoint(featureEdge[0]);
|
if (cutoutPolygons.Count == 0 || featureEdge.Count == 0)
|
||||||
var featureEnd = GetEndPoint(featureEdge[^1]);
|
|
||||||
var isVertical = axis == CutOffAxis.Vertical;
|
|
||||||
|
|
||||||
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
|
|
||||||
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
|
|
||||||
|
|
||||||
if (edgeGoesForward != featureGoesForward)
|
|
||||||
{
|
|
||||||
featureEdge = new List<Entity>(featureEdge);
|
|
||||||
featureEdge.Reverse();
|
|
||||||
foreach (var e in featureEdge)
|
|
||||||
e.Reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
return featureEdge;
|
return featureEdge;
|
||||||
|
|
||||||
|
var result = new List<Entity>();
|
||||||
|
foreach (var entity in featureEdge)
|
||||||
|
{
|
||||||
|
if (entity is Line line)
|
||||||
|
result.AddRange(SubtractCutoutsFromLine(line, cutoutPolygons));
|
||||||
|
else
|
||||||
|
result.Add(entity);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnsurePerimeterWinding(List<Entity> entities)
|
/// <summary>
|
||||||
|
/// Returns the sub-segments of <paramref name="line"/> that lie outside every
|
||||||
|
/// cutout polygon. Handles the common axis-aligned feature-edge case exactly.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Line> SubtractCutoutsFromLine(Line line, List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
var shape = new Shape();
|
// Collect parameter values t in [0,1] where the line crosses any cutout edge.
|
||||||
shape.Entities.AddRange(entities);
|
var ts = new List<double> { 0.0, 1.0 };
|
||||||
var poly = shape.ToPolygon();
|
foreach (var poly in cutoutPolygons)
|
||||||
if (poly != null && poly.RotationDirection() != RotationType.CW)
|
{
|
||||||
shape.Reverse();
|
var polyLines = poly.ToLines();
|
||||||
|
foreach (var edge in polyLines)
|
||||||
|
{
|
||||||
|
if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t))
|
||||||
|
{
|
||||||
|
if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon)
|
||||||
|
ts.Add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
entities.Clear();
|
ts.Sort();
|
||||||
entities.AddRange(shape.Entities);
|
|
||||||
|
var segments = new List<Line>();
|
||||||
|
for (var i = 0; i < ts.Count - 1; i++)
|
||||||
|
{
|
||||||
|
var t0 = ts[i];
|
||||||
|
var t1 = ts[i + 1];
|
||||||
|
if (t1 - t0 < Math.Tolerance.Epsilon) continue;
|
||||||
|
|
||||||
|
var tMid = (t0 + t1) * 0.5;
|
||||||
|
var mid = new Vector(
|
||||||
|
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * tMid,
|
||||||
|
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * tMid);
|
||||||
|
|
||||||
|
var insideCutout = false;
|
||||||
|
foreach (var poly in cutoutPolygons)
|
||||||
|
{
|
||||||
|
if (poly.ContainsPoint(mid))
|
||||||
|
{
|
||||||
|
insideCutout = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (insideCutout) continue;
|
||||||
|
|
||||||
|
var p0 = new Vector(
|
||||||
|
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t0,
|
||||||
|
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t0);
|
||||||
|
var p1 = new Vector(
|
||||||
|
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t1,
|
||||||
|
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t1);
|
||||||
|
|
||||||
|
segments.Add(new Line(p0, p1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Segment-segment intersection. On hit, returns the parameter t along segment AB
|
||||||
|
/// (0 = a0, 1 = a1) via <paramref name="tOnA"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryIntersectSegments(Vector a0, Vector a1, Vector b0, Vector b1, out double tOnA)
|
||||||
|
{
|
||||||
|
tOnA = 0;
|
||||||
|
var rx = a1.X - a0.X;
|
||||||
|
var ry = a1.Y - a0.Y;
|
||||||
|
var sx = b1.X - b0.X;
|
||||||
|
var sy = b1.Y - b0.Y;
|
||||||
|
|
||||||
|
var denom = rx * sy - ry * sx;
|
||||||
|
if (System.Math.Abs(denom) < Math.Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var dx = b0.X - a0.X;
|
||||||
|
var dy = b0.Y - a0.Y;
|
||||||
|
var t = (dx * sy - dy * sx) / denom;
|
||||||
|
var u = (dx * ry - dy * rx) / denom;
|
||||||
|
|
||||||
|
if (t < -Math.Tolerance.Epsilon || t > 1 + Math.Tolerance.Epsilon) return false;
|
||||||
|
if (u < -Math.Tolerance.Epsilon || u > 1 + Math.Tolerance.Epsilon) return false;
|
||||||
|
|
||||||
|
tOnA = t;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsCutoutInRegion(Shape cutout, Box region)
|
private static bool IsCutoutInRegion(Shape cutout, Box region)
|
||||||
{
|
{
|
||||||
if (cutout.Entities.Count == 0) return false;
|
if (cutout.Entities.Count == 0) return false;
|
||||||
var pt = GetStartPoint(cutout.Entities[0]);
|
var bb = cutout.BoundingBox;
|
||||||
return region.Contains(pt);
|
// Fully contained iff the cutout's bounding box fits inside the region.
|
||||||
|
return bb.Left >= region.Left - Math.Tolerance.Epsilon
|
||||||
|
&& bb.Right <= region.Right + Math.Tolerance.Epsilon
|
||||||
|
&& bb.Bottom >= region.Bottom - Math.Tolerance.Epsilon
|
||||||
|
&& bb.Top <= region.Top + Math.Tolerance.Epsilon;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
|
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
|
||||||
@@ -479,57 +555,135 @@ public static class DrawingSplitter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clip a cutout shape to a region by walking entities, splitting at split line
|
/// Clip a cutout shape to a region by walking entities and splitting at split-line
|
||||||
/// intersections, keeping portions inside the region, and closing gaps with
|
/// crossings. Only returns the cutout-edge fragments that lie inside the region —
|
||||||
/// straight lines. No polygon clipping library needed.
|
/// it deliberately does NOT emit synthetic closing lines at the region boundary.
|
||||||
|
///
|
||||||
|
/// Rationale: a closing line on the region boundary would overlap the split-line
|
||||||
|
/// feature edge and reintroduce a cut through the cutout interior. The feature
|
||||||
|
/// edge (trimmed against cutouts in <see cref="InsertFeatureEdges"/>) and these
|
||||||
|
/// cutout fragments are stitched together later by <see cref="AssemblePieces"/>
|
||||||
|
/// using endpoint connectivity, which produces the correct closed loops — one
|
||||||
|
/// loop per physically-connected strip of material.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
||||||
{
|
{
|
||||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
|
||||||
|
|
||||||
foreach (var entity in cutout.Entities)
|
foreach (var entity in cutout.Entities)
|
||||||
{
|
ProcessEntity(entity, region, entities);
|
||||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
return entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entities.Count == 0)
|
/// <summary>
|
||||||
return new List<Entity>();
|
/// Groups a region's entities into closed components and nests holes inside
|
||||||
|
/// outer loops by point-in-polygon containment. Returns one entity list per
|
||||||
// Close gaps with straight lines (connect exit→entry pairs)
|
/// output <see cref="Drawing"/> — outer loop first, then its contained holes.
|
||||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
/// Each outer loop is normalized to CW winding and each hole to CCW.
|
||||||
foreach (var sp in splitPoints)
|
/// </summary>
|
||||||
|
private static List<List<Entity>> AssemblePieces(List<Entity> entities)
|
||||||
{
|
{
|
||||||
if (!groups.ContainsKey(sp.Line))
|
var pieces = new List<List<Entity>>();
|
||||||
groups[sp.Line] = new List<(Vector, bool)>();
|
if (entities.Count == 0) return pieces;
|
||||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
|
if (shapes.Count == 0) return pieces;
|
||||||
|
|
||||||
|
// Polygonize every shape once so we can run containment tests.
|
||||||
|
var polygons = new List<Polygon>(shapes.Count);
|
||||||
|
foreach (var s in shapes)
|
||||||
|
polygons.Add(s.ToPolygon());
|
||||||
|
|
||||||
|
// Classify each shape as outer or hole using nesting by containment.
|
||||||
|
// Shape A is contained in shape B iff A's bounding box is strictly inside
|
||||||
|
// B's bounding box AND a representative vertex of A lies inside B's polygon.
|
||||||
|
// The bbox pre-check avoids the ambiguity of bbox-center tests when two
|
||||||
|
// shapes share a center (e.g., an outer half and a centered cutout).
|
||||||
|
var isHole = new bool[shapes.Count];
|
||||||
|
for (var i = 0; i < shapes.Count; i++)
|
||||||
|
{
|
||||||
|
var bbA = shapes[i].BoundingBox;
|
||||||
|
var repA = FirstVertexOf(shapes[i]);
|
||||||
|
|
||||||
|
for (var j = 0; j < shapes.Count; j++)
|
||||||
|
{
|
||||||
|
if (i == j) continue;
|
||||||
|
if (polygons[j] == null) continue;
|
||||||
|
if (polygons[j].Vertices.Count < 3) continue;
|
||||||
|
|
||||||
|
var bbB = shapes[j].BoundingBox;
|
||||||
|
if (!BoxContainsBox(bbB, bbA)) continue;
|
||||||
|
if (!polygons[j].ContainsPoint(repA)) continue;
|
||||||
|
|
||||||
|
isHole[i] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var kvp in groups)
|
// For each outer, attach the holes that fall inside it.
|
||||||
|
for (var i = 0; i < shapes.Count; i++)
|
||||||
{
|
{
|
||||||
var sl = kvp.Key;
|
if (isHole[i]) continue;
|
||||||
var points = kvp.Value;
|
|
||||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
|
||||||
|
|
||||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point)
|
var outer = shapes[i];
|
||||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
var outerPoly = polygons[i];
|
||||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
|
|
||||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
|
||||||
|
|
||||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
// Enforce perimeter winding = CW.
|
||||||
for (var i = 0; i < pairCount; i++)
|
if (outerPoly != null && outerPoly.Vertices.Count >= 3
|
||||||
entities.Add(new Line(exits[i], entries[i]));
|
&& outerPoly.RotationDirection() != RotationType.CW)
|
||||||
|
outer.Reverse();
|
||||||
|
|
||||||
|
var piece = new List<Entity>();
|
||||||
|
piece.AddRange(outer.Entities);
|
||||||
|
|
||||||
|
for (var j = 0; j < shapes.Count; j++)
|
||||||
|
{
|
||||||
|
if (!isHole[j]) continue;
|
||||||
|
if (polygons[i] == null || polygons[i].Vertices.Count < 3) continue;
|
||||||
|
|
||||||
|
var bbJ = shapes[j].BoundingBox;
|
||||||
|
if (!BoxContainsBox(shapes[i].BoundingBox, bbJ)) continue;
|
||||||
|
|
||||||
|
var rep = FirstVertexOf(shapes[j]);
|
||||||
|
if (!polygons[i].ContainsPoint(rep)) continue;
|
||||||
|
|
||||||
|
var hole = shapes[j];
|
||||||
|
var holePoly = polygons[j];
|
||||||
|
if (holePoly != null && holePoly.Vertices.Count >= 3
|
||||||
|
&& holePoly.RotationDirection() != RotationType.CCW)
|
||||||
|
hole.Reverse();
|
||||||
|
|
||||||
|
piece.AddRange(hole.Entities);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure CCW winding for cutouts
|
pieces.Add(piece);
|
||||||
var shape = new Shape();
|
}
|
||||||
shape.Entities.AddRange(entities);
|
|
||||||
var poly = shape.ToPolygon();
|
|
||||||
if (poly != null && poly.RotationDirection() != RotationType.CCW)
|
|
||||||
shape.Reverse();
|
|
||||||
|
|
||||||
return shape.Entities;
|
return pieces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the first vertex of a shape (start point of its first entity). Used as
|
||||||
|
/// a representative for containment testing: if bbox pre-check says the whole
|
||||||
|
/// shape is inside another, testing one vertex is sufficient to confirm.
|
||||||
|
/// </summary>
|
||||||
|
private static Vector FirstVertexOf(Shape shape)
|
||||||
|
{
|
||||||
|
if (shape.Entities.Count == 0)
|
||||||
|
return new Vector(0, 0);
|
||||||
|
return GetStartPoint(shape.Entities[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True iff box <paramref name="inner"/> is entirely inside box
|
||||||
|
/// <paramref name="outer"/> (tolerant comparison).
|
||||||
|
/// </summary>
|
||||||
|
private static bool BoxContainsBox(Box outer, Box inner)
|
||||||
|
{
|
||||||
|
var eps = Math.Tolerance.Epsilon;
|
||||||
|
return inner.Left >= outer.Left - eps
|
||||||
|
&& inner.Right <= outer.Right + eps
|
||||||
|
&& inner.Bottom >= outer.Bottom - eps
|
||||||
|
&& inner.Top <= outer.Top + eps;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector GetStartPoint(Entity entity)
|
private static Vector GetStartPoint(Entity entity)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ namespace OpenNest.Engine.BestFit
|
|||||||
if (_cache.TryGetValue(key, out var cached))
|
if (_cache.TryGetValue(key, out var cached))
|
||||||
return cached;
|
return cached;
|
||||||
|
|
||||||
|
// Operate on the canonical frame so cached pair positions are orientation-invariant.
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
IPairEvaluator evaluator = null;
|
IPairEvaluator evaluator = null;
|
||||||
ISlideComputer slideComputer = null;
|
ISlideComputer slideComputer = null;
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
{
|
{
|
||||||
if (CreateEvaluator != null)
|
if (CreateEvaluator != null)
|
||||||
{
|
{
|
||||||
try { evaluator = CreateEvaluator(drawing, spacing); }
|
try { evaluator = CreateEvaluator(canonical, spacing); }
|
||||||
catch { /* fall back to default evaluator */ }
|
catch { /* fall back to default evaluator */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
}
|
}
|
||||||
|
|
||||||
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
|
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
|
||||||
var results = finder.FindBestFits(drawing, spacing, StepSize);
|
var results = finder.FindBestFits(canonical, spacing, StepSize);
|
||||||
|
|
||||||
_cache.TryAdd(key, results);
|
_cache.TryAdd(key, results);
|
||||||
return results;
|
return results;
|
||||||
@@ -86,9 +89,12 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Operate on the canonical frame so cached pair positions are orientation-invariant.
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
if (CreateEvaluator != null)
|
if (CreateEvaluator != null)
|
||||||
{
|
{
|
||||||
try { evaluator = CreateEvaluator(drawing, spacing); }
|
try { evaluator = CreateEvaluator(canonical, spacing); }
|
||||||
catch { /* fall back to default evaluator */ }
|
catch { /* fall back to default evaluator */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +106,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
// Compute candidates and evaluate once with the largest plate.
|
// Compute candidates and evaluate once with the largest plate.
|
||||||
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
|
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
|
||||||
var baseResults = finder.FindBestFits(drawing, spacing, StepSize);
|
var baseResults = finder.FindBestFits(canonical, spacing, StepSize);
|
||||||
|
|
||||||
// Cache a filtered copy for each plate size.
|
// Cache a filtered copy for each plate size.
|
||||||
foreach (var size in needed)
|
foreach (var size in needed)
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace OpenNest.Engine.BestFit
|
namespace OpenNest.Engine.BestFit
|
||||||
{
|
{
|
||||||
public class NfpSlideStrategy : IBestFitStrategy
|
public class NfpSlideStrategy : IBestFitStrategy
|
||||||
{
|
{
|
||||||
private static readonly string LogPath = Path.Combine(
|
|
||||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop),
|
|
||||||
"nfp-slide-debug.log");
|
|
||||||
|
|
||||||
private static readonly object LogLock = new object();
|
|
||||||
|
|
||||||
private readonly double _part2Rotation;
|
private readonly double _part2Rotation;
|
||||||
private readonly Polygon _stationaryPerimeter;
|
private readonly Polygon _stationaryPerimeter;
|
||||||
private readonly Polygon _stationaryHull;
|
private readonly Polygon _stationaryHull;
|
||||||
@@ -46,12 +38,6 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
var hull = ConvexHull.Compute(result.Polygon.Vertices);
|
var hull = ConvexHull.Compute(result.Polygon.Vertices);
|
||||||
|
|
||||||
Log($"=== Create: drawing={drawing.Name}, rotation={Angle.ToDegrees(part2Rotation):F1}deg ===");
|
|
||||||
Log($" Perimeter: {result.Polygon.Vertices.Count} verts, bounds={FormatBounds(result.Polygon)}");
|
|
||||||
Log($" Hull: {hull.Vertices.Count} verts, bounds={FormatBounds(hull)}");
|
|
||||||
Log($" Correction: ({result.Correction.X:F4}, {result.Correction.Y:F4})");
|
|
||||||
Log($" ProgramBBox: {drawing.Program.BoundingBox()}");
|
|
||||||
|
|
||||||
return new NfpSlideStrategy(part2Rotation, type, description,
|
return new NfpSlideStrategy(part2Rotation, type, description,
|
||||||
result.Polygon, hull, result.Correction);
|
result.Polygon, hull, result.Correction);
|
||||||
}
|
}
|
||||||
@@ -63,40 +49,17 @@ namespace OpenNest.Engine.BestFit
|
|||||||
if (stepSize <= 0)
|
if (stepSize <= 0)
|
||||||
return candidates;
|
return candidates;
|
||||||
|
|
||||||
Log($"--- GenerateCandidates: drawing={drawing.Name}, part2Rot={Angle.ToDegrees(_part2Rotation):F1}deg, spacing={spacing}, stepSize={stepSize} ---");
|
|
||||||
|
|
||||||
// Orbiting polygon: same shape rotated to Part2's angle.
|
|
||||||
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
|
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
|
||||||
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
|
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
|
||||||
|
|
||||||
Log($" Stationary hull: {_stationaryHull.Vertices.Count} verts, bounds={FormatBounds(_stationaryHull)}");
|
|
||||||
Log($" Orbiting perimeter (rotated): {orbitingPerimeter.Vertices.Count} verts, bounds={FormatBounds(orbitingPerimeter)}");
|
|
||||||
Log($" Orbiting hull: {orbitingPoly.Vertices.Count} verts, bounds={FormatBounds(orbitingPoly)}");
|
|
||||||
|
|
||||||
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
|
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
|
||||||
|
|
||||||
if (nfp == null || nfp.Vertices.Count < 3)
|
if (nfp == null || nfp.Vertices.Count < 3)
|
||||||
{
|
|
||||||
Log($" NFP failed or degenerate (verts={nfp?.Vertices.Count ?? 0})");
|
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
|
||||||
|
|
||||||
var verts = nfp.Vertices;
|
var verts = nfp.Vertices;
|
||||||
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
|
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
|
||||||
|
|
||||||
Log($" NFP: {verts.Count} verts (closed={nfp.IsClosed()}, walking {vertCount}), bounds={FormatBounds(nfp)}");
|
|
||||||
Log($" Correction: ({_correction.X:F4}, {_correction.Y:F4})");
|
|
||||||
|
|
||||||
// Log NFP vertices
|
|
||||||
for (var v = 0; v < vertCount; v++)
|
|
||||||
Log($" NFP vert[{v}]: ({verts[v].X:F4}, {verts[v].Y:F4}) -> corrected: ({verts[v].X - _correction.X:F4}, {verts[v].Y - _correction.Y:F4})");
|
|
||||||
|
|
||||||
// Compare with what RotationSlideStrategy would produce
|
|
||||||
var part1 = Part.CreateAtOrigin(drawing);
|
|
||||||
var part2 = Part.CreateAtOrigin(drawing, _part2Rotation);
|
|
||||||
Log($" Part1 (rot=0): loc=({part1.Location.X:F4}, {part1.Location.Y:F4}), bbox={part1.BoundingBox}");
|
|
||||||
Log($" Part2 (rot={Angle.ToDegrees(_part2Rotation):F1}): loc=({part2.Location.X:F4}, {part2.Location.Y:F4}), bbox={part2.BoundingBox}");
|
|
||||||
|
|
||||||
var testNumber = 0;
|
var testNumber = 0;
|
||||||
|
|
||||||
for (var i = 0; i < vertCount; i++)
|
for (var i = 0; i < vertCount; i++)
|
||||||
@@ -125,20 +88,6 @@ namespace OpenNest.Engine.BestFit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log overlap check for vertex candidates (first few)
|
|
||||||
var checkCount = System.Math.Min(vertCount, 8);
|
|
||||||
for (var c = 0; c < checkCount; c++)
|
|
||||||
{
|
|
||||||
var cand = candidates[c];
|
|
||||||
var p2 = Part.CreateAtOrigin(drawing, cand.Part2Rotation);
|
|
||||||
p2.Location = cand.Part2Offset;
|
|
||||||
var overlaps = part1.Intersects(p2, out _);
|
|
||||||
Log($" Candidate[{c}]: offset=({cand.Part2Offset.X:F4}, {cand.Part2Offset.Y:F4}), overlaps={overlaps}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Log($" Total candidates: {candidates.Count}");
|
|
||||||
Log("");
|
|
||||||
|
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +109,5 @@ namespace OpenNest.Engine.BestFit
|
|||||||
Spacing = spacing
|
Spacing = spacing
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatBounds(Polygon polygon)
|
|
||||||
{
|
|
||||||
polygon.UpdateBounds();
|
|
||||||
var bb = polygon.BoundingBox;
|
|
||||||
return $"[({bb.Left:F4}, {bb.Bottom:F4})-({bb.Right:F4}, {bb.Top:F4}), {bb.Width:F2}x{bb.Length:F2}]";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Log(string message)
|
|
||||||
{
|
|
||||||
lock (LogLock)
|
|
||||||
{
|
|
||||||
File.AppendAllText(LogPath, message + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Produces transient canonical (MBR-axis-aligned) copies of drawings for engine consumption
|
||||||
|
/// and un-rotates placed parts back to the drawing's original frame.
|
||||||
|
/// </summary>
|
||||||
|
public static class CanonicalFrame
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
|
||||||
|
/// The source drawing is not mutated.
|
||||||
|
/// </summary>
|
||||||
|
public static Drawing AsCanonicalCopy(Drawing drawing)
|
||||||
|
{
|
||||||
|
if (drawing == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var angle = drawing.Source?.Angle ?? 0.0;
|
||||||
|
|
||||||
|
// Clone program (never mutate the source).
|
||||||
|
var pgm = (drawing.Program.Clone() as OpenNest.CNC.Program)
|
||||||
|
?? new OpenNest.CNC.Program();
|
||||||
|
|
||||||
|
if (!Tolerance.IsEqualTo(angle, 0))
|
||||||
|
pgm.Rotate(angle, pgm.BoundingBox().Center);
|
||||||
|
|
||||||
|
var copy = new Drawing(drawing.Name ?? string.Empty, pgm)
|
||||||
|
{
|
||||||
|
Color = drawing.Color,
|
||||||
|
Constraints = drawing.Constraints,
|
||||||
|
Material = drawing.Material,
|
||||||
|
Priority = drawing.Priority,
|
||||||
|
Customer = drawing.Customer,
|
||||||
|
IsCutOff = drawing.IsCutOff,
|
||||||
|
Source = new SourceInfo
|
||||||
|
{
|
||||||
|
Path = drawing.Source?.Path,
|
||||||
|
Offset = drawing.Source?.Offset ?? new Vector(0, 0),
|
||||||
|
Angle = 0.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes the source drawing's canonical angle onto each placed part so the
|
||||||
|
/// returned list is in the drawing's original (visible) frame.
|
||||||
|
///
|
||||||
|
/// Derivation: let sourceAngle = S (rotation mapping source -> canonical).
|
||||||
|
/// Canonical part at rotation R shows visible orientation R.
|
||||||
|
/// Source part at rotation R' shows visible orientation R' + (-S), because the
|
||||||
|
/// source geometry is already rotated by -S relative to canonical.
|
||||||
|
/// Setting equal gives R' = R + S, so we ADD sourceAngle to each placed part.
|
||||||
|
///
|
||||||
|
/// Rotation is performed around the part's Location so its placement position is preserved;
|
||||||
|
/// only the orientation composes.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Part> FromCanonical(List<Part> placed, double sourceAngle)
|
||||||
|
{
|
||||||
|
if (placed == null || placed.Count == 0)
|
||||||
|
return placed;
|
||||||
|
if (Tolerance.IsEqualTo(sourceAngle, 0))
|
||||||
|
return placed;
|
||||||
|
|
||||||
|
foreach (var p in placed)
|
||||||
|
p.Rotate(sourceAngle, p.Location);
|
||||||
|
|
||||||
|
return placed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,14 +47,29 @@ namespace OpenNest
|
|||||||
PhaseResults.Clear();
|
PhaseResults.Clear();
|
||||||
AngleResults.Clear();
|
AngleResults.Clear();
|
||||||
|
|
||||||
// Fast path: for very small quantities, skip the full strategy pipeline.
|
// Replace the item's Drawing with a canonical copy for the duration of this fill.
|
||||||
if (item.Quantity > 0 && item.Quantity <= 2)
|
// All internal methods see canonical geometry; this wrapper un-canonicalizes the final result.
|
||||||
|
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
|
||||||
|
var originalDrawing = item.Drawing;
|
||||||
|
var canonicalItem = new NestItem
|
||||||
{
|
{
|
||||||
var fast = TryFillSmallQuantity(item, workArea);
|
Drawing = CanonicalFrame.AsCanonicalCopy(item.Drawing),
|
||||||
if (fast != null && fast.Count >= item.Quantity)
|
Quantity = item.Quantity,
|
||||||
|
Priority = item.Priority,
|
||||||
|
RotationStart = item.RotationStart,
|
||||||
|
RotationEnd = item.RotationEnd,
|
||||||
|
StepAngle = item.StepAngle,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fast path for qty 1-2.
|
||||||
|
if (canonicalItem.Quantity > 0 && canonicalItem.Quantity <= 2)
|
||||||
{
|
{
|
||||||
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={item.Quantity}");
|
var fast = TryFillSmallQuantity(canonicalItem, workArea);
|
||||||
|
if (fast != null && fast.Count >= canonicalItem.Quantity)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}");
|
||||||
WinnerPhase = NestPhase.Pairs;
|
WinnerPhase = NestPhase.Pairs;
|
||||||
|
fast = RebindAndUnCanonicalize(fast, originalDrawing, sourceAngle);
|
||||||
ReportProgress(progress, new ProgressReport
|
ReportProgress(progress, new ProgressReport
|
||||||
{
|
{
|
||||||
Phase = WinnerPhase,
|
Phase = WinnerPhase,
|
||||||
@@ -68,32 +83,30 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For low quantities, shrink the work area in both dimensions to avoid
|
|
||||||
// running expensive strategies against the full plate.
|
|
||||||
var effectiveWorkArea = workArea;
|
var effectiveWorkArea = workArea;
|
||||||
if (item.Quantity > 0)
|
if (canonicalItem.Quantity > 0)
|
||||||
{
|
{
|
||||||
effectiveWorkArea = ShrinkWorkArea(item, workArea, Plate.PartSpacing);
|
effectiveWorkArea = ShrinkWorkArea(canonicalItem, workArea, Plate.PartSpacing);
|
||||||
|
|
||||||
if (effectiveWorkArea != workArea)
|
if (effectiveWorkArea != workArea)
|
||||||
Debug.WriteLine($"[Fill] Low-qty shrink: {item.Quantity} requested, " +
|
Debug.WriteLine($"[Fill] Low-qty shrink: {canonicalItem.Quantity} requested, " +
|
||||||
$"from {workArea.Width:F1}x{workArea.Length:F1} " +
|
$"from {workArea.Width:F1}x{workArea.Length:F1} " +
|
||||||
$"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}");
|
$"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var best = RunFillPipeline(item, effectiveWorkArea, progress, token);
|
var best = RunFillPipeline(canonicalItem, effectiveWorkArea, progress, token);
|
||||||
|
|
||||||
// Fallback: if the reduced area didn't yield enough, retry with full area.
|
if (canonicalItem.Quantity > 0 && best.Count < canonicalItem.Quantity && effectiveWorkArea != workArea)
|
||||||
if (item.Quantity > 0 && best.Count < item.Quantity && effectiveWorkArea != workArea)
|
|
||||||
{
|
{
|
||||||
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {item.Quantity}, retrying full area");
|
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {canonicalItem.Quantity}, retrying full area");
|
||||||
PhaseResults.Clear();
|
PhaseResults.Clear();
|
||||||
AngleResults.Clear();
|
AngleResults.Clear();
|
||||||
best = RunFillPipeline(item, workArea, progress, token);
|
best = RunFillPipeline(canonicalItem, workArea, progress, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity)
|
||||||
best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis);
|
best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis);
|
||||||
|
|
||||||
|
best = RebindAndUnCanonicalize(best, originalDrawing, sourceAngle);
|
||||||
|
|
||||||
ReportProgress(progress, new ProgressReport
|
ReportProgress(progress, new ProgressReport
|
||||||
{
|
{
|
||||||
@@ -108,6 +121,31 @@ namespace OpenNest
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single exit point for canonical -> source frame conversion. Rebinds every Part to the
|
||||||
|
/// original Drawing (so consumers see the user's drawing identity, not the transient canonical copy)
|
||||||
|
/// and composes sourceAngle onto each Part's rotation via CanonicalFrame.FromCanonical.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Part> RebindAndUnCanonicalize(List<Part> parts, Drawing original, double sourceAngle)
|
||||||
|
{
|
||||||
|
if (parts == null || parts.Count == 0)
|
||||||
|
return parts;
|
||||||
|
|
||||||
|
for (var i = 0; i < parts.Count; i++)
|
||||||
|
{
|
||||||
|
var p = parts[i];
|
||||||
|
// Rebind to `original` while preserving world pose. CreateAtOrigin rotates
|
||||||
|
// at the origin (keeping bbox at world (0,0)) then we offset to match p's bbox.
|
||||||
|
var rebound = Part.CreateAtOrigin(original, p.Rotation);
|
||||||
|
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
|
||||||
|
rebound.Offset(delta);
|
||||||
|
rebound.UpdateBounds();
|
||||||
|
parts[i] = rebound;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CanonicalFrame.FromCanonical(parts, sourceAngle);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fast path for qty 1-2: place a single part or a best-fit pair
|
/// Fast path for qty 1-2: place a single part or a best-fit pair
|
||||||
/// without running the full strategy pipeline.
|
/// without running the full strategy pipeline.
|
||||||
@@ -139,6 +177,10 @@ namespace OpenNest
|
|||||||
var bestFits = BestFitCache.GetOrCompute(
|
var bestFits = BestFitCache.GetOrCompute(
|
||||||
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
||||||
|
|
||||||
|
// Build pair candidates with a canonical drawing so their geometry matches
|
||||||
|
// the coordinate frame of the cached fit results.
|
||||||
|
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
List<Part> bestPlacement = null;
|
List<Part> bestPlacement = null;
|
||||||
|
|
||||||
foreach (var fit in bestFits)
|
foreach (var fit in bestFits)
|
||||||
@@ -152,7 +194,7 @@ namespace OpenNest
|
|||||||
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
|
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var landscape = fit.BuildParts(drawing);
|
var landscape = fit.BuildParts(canonicalDrawing);
|
||||||
var portrait = RotatePair90(landscape);
|
var portrait = RotatePair90(landscape);
|
||||||
|
|
||||||
var lFits = TryOffsetToWorkArea(landscape, workArea);
|
var lFits = TryOffsetToWorkArea(landscape, workArea);
|
||||||
@@ -174,6 +216,8 @@ namespace OpenNest
|
|||||||
bestPlacement = candidate;
|
bestPlacement = candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parts are returned in canonical frame, bound to the canonical drawing.
|
||||||
|
// The outer Fill wrapper (Task 7) rebinds to `drawing` and composes sourceAngle onto rotation.
|
||||||
return bestPlacement;
|
return bestPlacement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,92 +61,91 @@ namespace OpenNest.Engine.Fill
|
|||||||
: NestDirection.Horizontal;
|
: NestDirection.Horizontal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Computes the slide distance for the push algorithm, returning the
|
|
||||||
/// geometry-aware copy distance along the given axis.
|
|
||||||
/// </summary>
|
|
||||||
private double ComputeCopyDistance(double bboxDim, double slideDistance)
|
|
||||||
{
|
|
||||||
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
|
||||||
return bboxDim + PartSpacing;
|
|
||||||
|
|
||||||
// The geometry-aware slide can produce a copy distance smaller than
|
|
||||||
// the part itself when inflated corner/arc vertices interact spuriously.
|
|
||||||
// Clamp to bboxDim + PartSpacing to prevent bounding box overlap.
|
|
||||||
return System.Math.Max(bboxDim - slideDistance, bboxDim + PartSpacing);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds the geometry-aware copy distance between two identical parts along an axis.
|
/// Finds the geometry-aware copy distance between two identical parts along an axis.
|
||||||
/// Both parts are inflated by half-spacing for symmetric spacing.
|
/// Uses native Line/Arc entities (inflated by half-spacing) so curves are handled
|
||||||
|
/// exactly without polygon sampling error.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private double FindCopyDistance(Part partA, NestDirection direction, PartBoundary boundary)
|
private double FindCopyDistance(Part partA, NestDirection direction)
|
||||||
{
|
{
|
||||||
var bboxDim = GetDimension(partA.BoundingBox, direction);
|
var bboxDim = GetDimension(partA.BoundingBox, direction);
|
||||||
var pushDir = GetPushDirection(direction);
|
var pushDir = GetPushDirection(direction);
|
||||||
|
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
|
||||||
|
var offset = MakeOffset(direction, startOffset);
|
||||||
|
|
||||||
var locationBOffset = MakeOffset(direction, bboxDim);
|
var stationaryEntities = PartGeometry.GetOffsetPerimeterEntities(partA, HalfSpacing);
|
||||||
|
var movingEntities = PartGeometry.GetOffsetPerimeterEntities(
|
||||||
|
partA.CloneAtOffset(offset), HalfSpacing);
|
||||||
|
|
||||||
// Use the most efficient array-based overload to avoid all allocations.
|
|
||||||
var slideDistance = SpatialQuery.DirectionalDistance(
|
var slideDistance = SpatialQuery.DirectionalDistance(
|
||||||
boundary.GetEdges(pushDir), partA.Location + locationBOffset,
|
movingEntities, stationaryEntities, pushDir);
|
||||||
boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location,
|
|
||||||
pushDir);
|
|
||||||
|
|
||||||
return ComputeCopyDistance(bboxDim, slideDistance);
|
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
||||||
|
return bboxDim + PartSpacing;
|
||||||
|
|
||||||
|
return startOffset - slideDistance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds the geometry-aware copy distance between two identical patterns along an axis.
|
/// Finds the geometry-aware copy distance between two identical patterns along an axis.
|
||||||
/// Checks every pair of parts across adjacent patterns so that multi-part
|
/// Checks every pair of parts across adjacent pattern copies so multi-part patterns
|
||||||
/// patterns (e.g. interlocking pairs) maintain spacing between ALL parts.
|
/// (e.g. interlocking pairs) maintain spacing between ALL parts. Uses native entity
|
||||||
/// Both sides are inflated by half-spacing for symmetric spacing.
|
/// geometry inflated by half-spacing — same primitive the Compactor uses — so arcs
|
||||||
|
/// are exact and no bbox clamp is needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary[] boundaries)
|
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction)
|
||||||
{
|
{
|
||||||
if (patternA.Parts.Count <= 1)
|
if (patternA.Parts.Count == 1)
|
||||||
return FindSinglePartPatternCopyDistance(patternA, direction, boundaries[0]);
|
return FindCopyDistance(patternA.Parts[0], direction);
|
||||||
|
|
||||||
var bboxDim = GetDimension(patternA.BoundingBox, direction);
|
var bboxDim = GetDimension(patternA.BoundingBox, direction);
|
||||||
var pushDir = GetPushDirection(direction);
|
var pushDir = GetPushDirection(direction);
|
||||||
var opposite = SpatialQuery.OppositeDirection(pushDir);
|
var opposite = SpatialQuery.OppositeDirection(pushDir);
|
||||||
|
var dirVec = SpatialQuery.DirectionToOffset(pushDir, 1.0);
|
||||||
|
|
||||||
// bboxDim already spans max(upper) - min(lower) across all parts,
|
// bboxDim already spans max(upper) - min(lower) across all parts,
|
||||||
// so the start offset just needs to push beyond that plus spacing.
|
// so the start offset just needs to push beyond that plus spacing.
|
||||||
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
|
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
|
||||||
var offset = MakeOffset(direction, startOffset);
|
var offset = MakeOffset(direction, startOffset);
|
||||||
|
|
||||||
var maxCopyDistance = FindMaxPairDistance(
|
var parts = patternA.Parts;
|
||||||
patternA.Parts, boundaries, offset, pushDir, opposite, startOffset);
|
var stationaryBoxes = new Box[parts.Count];
|
||||||
|
var movingBoxes = new Box[parts.Count];
|
||||||
|
var stationaryEntities = new List<Entity>[parts.Count];
|
||||||
|
var movingEntities = new List<Entity>[parts.Count];
|
||||||
|
|
||||||
// The copy distance must be at least bboxDim + PartSpacing to prevent
|
for (var i = 0; i < parts.Count; i++)
|
||||||
// bounding box overlap. Cross-pair slides can underestimate when the
|
{
|
||||||
// circumscribed polygon boundary overshoots the true arc, creating
|
stationaryBoxes[i] = parts[i].BoundingBox;
|
||||||
// spurious contacts between diagonal parts in adjacent copies.
|
movingBoxes[i] = stationaryBoxes[i].Translate(offset);
|
||||||
return System.Math.Max(maxCopyDistance, bboxDim + PartSpacing);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests every pair of parts across adjacent pattern copies and returns the
|
|
||||||
/// maximum copy distance found. Returns 0 if no valid slide was found.
|
|
||||||
/// </summary>
|
|
||||||
private static double FindMaxPairDistance(
|
|
||||||
List<Part> parts, PartBoundary[] boundaries, Vector offset,
|
|
||||||
PushDirection pushDir, PushDirection opposite, double startOffset)
|
|
||||||
{
|
|
||||||
var maxCopyDistance = 0.0;
|
var maxCopyDistance = 0.0;
|
||||||
|
|
||||||
for (var j = 0; j < parts.Count; j++)
|
for (var j = 0; j < parts.Count; j++)
|
||||||
{
|
{
|
||||||
var movingEdges = boundaries[j].GetEdges(pushDir);
|
var movingBox = movingBoxes[j];
|
||||||
var locationB = parts[j].Location + offset;
|
|
||||||
|
|
||||||
for (var i = 0; i < parts.Count; i++)
|
for (var i = 0; i < parts.Count; i++)
|
||||||
{
|
{
|
||||||
|
var stationaryBox = stationaryBoxes[i];
|
||||||
|
|
||||||
|
// Skip if stationary is already ahead of moving in the push direction
|
||||||
|
// (sliding forward would take them further apart).
|
||||||
|
if (SpatialQuery.DirectionalGap(movingBox, stationaryBox, opposite) > 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Skip if bboxes can't overlap along the axis perpendicular to the push.
|
||||||
|
if (!SpatialQuery.PerpendicularOverlap(movingBox, stationaryBox, dirVec))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
stationaryEntities[i] ??= PartGeometry.GetOffsetPerimeterEntities(
|
||||||
|
parts[i], HalfSpacing);
|
||||||
|
movingEntities[j] ??= PartGeometry.GetOffsetPerimeterEntities(
|
||||||
|
parts[j].CloneAtOffset(offset), HalfSpacing);
|
||||||
|
|
||||||
var slideDistance = SpatialQuery.DirectionalDistance(
|
var slideDistance = SpatialQuery.DirectionalDistance(
|
||||||
movingEdges, locationB,
|
movingEntities[j], stationaryEntities[i], pushDir);
|
||||||
boundaries[i].GetEdges(opposite), parts[i].Location,
|
|
||||||
pushDir);
|
|
||||||
|
|
||||||
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
||||||
continue;
|
continue;
|
||||||
@@ -161,86 +160,15 @@ namespace OpenNest.Engine.Fill
|
|||||||
return maxCopyDistance;
|
return maxCopyDistance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fast path for single-part patterns — no cross-part conflicts possible.
|
|
||||||
/// </summary>
|
|
||||||
private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary)
|
|
||||||
{
|
|
||||||
var template = patternA.Parts[0];
|
|
||||||
return FindCopyDistance(template, direction, boundary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets offset boundary lines for all parts in a pattern using a shared boundary.
|
|
||||||
/// </summary>
|
|
||||||
private static List<Line> GetPatternLines(Pattern pattern, PartBoundary boundary, PushDirection direction)
|
|
||||||
{
|
|
||||||
var lines = new List<Line>();
|
|
||||||
|
|
||||||
foreach (var part in pattern.Parts)
|
|
||||||
lines.AddRange(boundary.GetLines(part.Location, direction));
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets boundary lines for all parts in a pattern, with an additional
|
|
||||||
/// location offset applied. Avoids cloning the pattern.
|
|
||||||
/// </summary>
|
|
||||||
private static List<Line> GetOffsetPatternLines(Pattern pattern, Vector offset, PartBoundary boundary, PushDirection direction)
|
|
||||||
{
|
|
||||||
var lines = new List<Line>();
|
|
||||||
|
|
||||||
foreach (var part in pattern.Parts)
|
|
||||||
lines.AddRange(boundary.GetLines(part.Location + offset, direction));
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates boundaries for all parts in a pattern. Parts that share the same
|
|
||||||
/// program geometry (same drawing and rotation) reuse the same boundary instance.
|
|
||||||
/// </summary>
|
|
||||||
private PartBoundary[] CreateBoundaries(Pattern pattern)
|
|
||||||
{
|
|
||||||
var boundaries = new PartBoundary[pattern.Parts.Count];
|
|
||||||
var cache = new List<(Drawing drawing, double rotation, PartBoundary boundary)>();
|
|
||||||
|
|
||||||
for (var i = 0; i < pattern.Parts.Count; i++)
|
|
||||||
{
|
|
||||||
var part = pattern.Parts[i];
|
|
||||||
PartBoundary found = null;
|
|
||||||
|
|
||||||
foreach (var entry in cache)
|
|
||||||
{
|
|
||||||
if (entry.drawing == part.BaseDrawing && entry.rotation.IsEqualTo(part.Rotation))
|
|
||||||
{
|
|
||||||
found = entry.boundary;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (found == null)
|
|
||||||
{
|
|
||||||
found = new PartBoundary(part, HalfSpacing);
|
|
||||||
cache.Add((part.BaseDrawing, part.Rotation, found));
|
|
||||||
}
|
|
||||||
|
|
||||||
boundaries[i] = found;
|
|
||||||
}
|
|
||||||
|
|
||||||
return boundaries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tiles a pattern along the given axis, returning the cloned parts
|
/// Tiles a pattern along the given axis, returning the cloned parts
|
||||||
/// (does not include the original pattern's parts). For multi-part
|
/// (does not include the original pattern's parts). For multi-part
|
||||||
/// patterns, also adds individual parts from the next incomplete copy
|
/// patterns, also adds individual parts from the next incomplete copy
|
||||||
/// that still fit within the work area.
|
/// that still fit within the work area.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, PartBoundary[] boundaries)
|
private List<Part> TilePattern(Pattern basePattern, NestDirection direction)
|
||||||
{
|
{
|
||||||
var copyDistance = FindPatternCopyDistance(basePattern, direction, boundaries);
|
var copyDistance = FindPatternCopyDistance(basePattern, direction);
|
||||||
|
|
||||||
if (copyDistance <= 0)
|
if (copyDistance <= 0)
|
||||||
return new List<Part>();
|
return new List<Part>();
|
||||||
@@ -394,11 +322,10 @@ namespace OpenNest.Engine.Fill
|
|||||||
private List<Part> FillGrid(Pattern pattern, NestDirection direction)
|
private List<Part> FillGrid(Pattern pattern, NestDirection direction)
|
||||||
{
|
{
|
||||||
var perpAxis = PerpendicularAxis(direction);
|
var perpAxis = PerpendicularAxis(direction);
|
||||||
var boundaries = CreateBoundaries(pattern);
|
|
||||||
|
|
||||||
// Step 1: Tile along primary axis
|
// Step 1: Tile along primary axis
|
||||||
var row = new List<Part>(pattern.Parts);
|
var row = new List<Part>(pattern.Parts);
|
||||||
row.AddRange(TilePattern(pattern, direction, boundaries));
|
row.AddRange(TilePattern(pattern, direction));
|
||||||
|
|
||||||
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1))
|
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1))
|
||||||
{
|
{
|
||||||
@@ -410,7 +337,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
// If primary tiling didn't produce copies, just tile along perpendicular
|
// If primary tiling didn't produce copies, just tile along perpendicular
|
||||||
if (row.Count <= pattern.Parts.Count)
|
if (row.Count <= pattern.Parts.Count)
|
||||||
{
|
{
|
||||||
row.AddRange(TilePattern(pattern, perpAxis, boundaries));
|
row.AddRange(TilePattern(pattern, perpAxis));
|
||||||
|
|
||||||
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
|
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
|
||||||
{
|
{
|
||||||
@@ -427,9 +354,8 @@ namespace OpenNest.Engine.Fill
|
|||||||
rowPattern.Parts.AddRange(row);
|
rowPattern.Parts.AddRange(row);
|
||||||
rowPattern.UpdateBounds();
|
rowPattern.UpdateBounds();
|
||||||
|
|
||||||
var rowBoundaries = CreateBoundaries(rowPattern);
|
|
||||||
var gridResult = new List<Part>(rowPattern.Parts);
|
var gridResult = new List<Part>(rowPattern.Parts);
|
||||||
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
|
gridResult.AddRange(TilePattern(rowPattern, perpAxis));
|
||||||
|
|
||||||
if (HasOverlappingParts(gridResult, out var a3, out var b3))
|
if (HasOverlappingParts(gridResult, out var a3, out var b3))
|
||||||
{
|
{
|
||||||
@@ -481,9 +407,8 @@ namespace OpenNest.Engine.Fill
|
|||||||
return seed;
|
return seed;
|
||||||
|
|
||||||
var template = seed.Parts[0];
|
var template = seed.Parts[0];
|
||||||
var boundary = new PartBoundary(template, HalfSpacing);
|
|
||||||
|
|
||||||
var copyDistance = FindCopyDistance(template, direction, boundary);
|
var copyDistance = FindCopyDistance(template, direction);
|
||||||
|
|
||||||
if (copyDistance <= 0)
|
if (copyDistance <= 0)
|
||||||
return seed;
|
return seed;
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ namespace OpenNest.Engine.ML
|
|||||||
{
|
{
|
||||||
public static PartFeatures Extract(Drawing drawing)
|
public static PartFeatures Extract(Drawing drawing)
|
||||||
{
|
{
|
||||||
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(drawing.Program)
|
// Normalize to canonical frame so features are invariant to import orientation.
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
|
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(canonical.Program)
|
||||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -45,18 +48,18 @@ namespace OpenNest.Engine.ML
|
|||||||
|
|
||||||
var features = new PartFeatures
|
var features = new PartFeatures
|
||||||
{
|
{
|
||||||
Area = drawing.Area,
|
Area = canonical.Area,
|
||||||
Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0),
|
Convexity = canonical.Area / (hullArea > 0 ? hullArea : 1.0),
|
||||||
AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0),
|
AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0),
|
||||||
BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
|
BoundingBoxFill = canonical.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
|
||||||
VertexCount = polygon.Vertices.Count,
|
VertexCount = polygon.Vertices.Count,
|
||||||
Bitmask = GenerateBitmask(polygon, 32)
|
Bitmask = GenerateBitmask(polygon, 32)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Circularity = 4 * PI * Area / Perimeter^2
|
// Circularity = 4 * PI * Area / Perimeter^2
|
||||||
var perimeterLen = polygon.Perimeter();
|
var perimeterLen = polygon.Perimeter();
|
||||||
features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen);
|
features.Circularity = (4 * System.Math.PI * canonical.Area) / (perimeterLen * perimeterLen);
|
||||||
features.PerimeterToAreaRatio = drawing.Area > 0 ? perimeterLen / drawing.Area : 0;
|
features.PerimeterToAreaRatio = canonical.Area > 0 ? perimeterLen / canonical.Area : 0;
|
||||||
|
|
||||||
return features;
|
return features;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,6 +334,12 @@ namespace OpenNest
|
|||||||
var bestFits = BestFitCache.GetOrCompute(
|
var bestFits = BestFitCache.GetOrCompute(
|
||||||
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
||||||
|
|
||||||
|
// BestFitCache stores pair coordinates in canonical frame. Build candidates
|
||||||
|
// from a canonical drawing copy so geometry and coords share a frame; rebind
|
||||||
|
// + un-rotate winning pair to the original drawing's frame before returning.
|
||||||
|
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
|
||||||
|
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
|
||||||
|
|
||||||
List<Part> bestPlacement = null;
|
List<Part> bestPlacement = null;
|
||||||
Box bestTarget = null;
|
Box bestTarget = null;
|
||||||
|
|
||||||
@@ -342,7 +348,7 @@ namespace OpenNest
|
|||||||
if (!fit.Keep)
|
if (!fit.Keep)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var parts = fit.BuildParts(item.Drawing);
|
var parts = fit.BuildParts(canonicalDrawing);
|
||||||
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||||
var pairW = pairBbox.Width;
|
var pairW = pairBbox.Width;
|
||||||
var pairL = pairBbox.Length;
|
var pairL = pairBbox.Length;
|
||||||
@@ -374,6 +380,10 @@ namespace OpenNest
|
|||||||
|
|
||||||
if (bestPlacement == null) continue;
|
if (bestPlacement == null) continue;
|
||||||
|
|
||||||
|
// Rebind to the original drawing and compose sourceAngle onto rotation so the
|
||||||
|
// final placed parts sit in the user's visible frame.
|
||||||
|
bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing, sourceAngle);
|
||||||
|
|
||||||
result.AddRange(bestPlacement);
|
result.AddRange(bestPlacement);
|
||||||
item.Quantity = 0;
|
item.Quantity = 0;
|
||||||
|
|
||||||
@@ -388,6 +398,30 @@ namespace OpenNest
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebinds each canonical-frame Part in the pair to the original Drawing at its current
|
||||||
|
/// world pose, then composes sourceAngle onto each via CanonicalFrame.FromCanonical so
|
||||||
|
/// the returned list is in the original drawing's visible frame. Mirrors
|
||||||
|
/// DefaultNestEngine.RebindAndUnCanonicalize.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Part> RebindPairToOriginal(List<Part> parts, Drawing original, double sourceAngle)
|
||||||
|
{
|
||||||
|
if (parts == null || parts.Count == 0)
|
||||||
|
return parts;
|
||||||
|
|
||||||
|
for (var i = 0; i < parts.Count; i++)
|
||||||
|
{
|
||||||
|
var p = parts[i];
|
||||||
|
var rebound = Part.CreateAtOrigin(original, p.Rotation);
|
||||||
|
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
|
||||||
|
rebound.Offset(delta);
|
||||||
|
rebound.UpdateBounds();
|
||||||
|
parts[i] = rebound;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CanonicalFrame.FromCanonical(parts, sourceAngle);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determines whether a drawing should use grid-fill (true) or bin-pack (false).
|
/// Determines whether a drawing should use grid-fill (true) or bin-pack (false).
|
||||||
/// Low-quantity items whose total area is a small fraction of the plate are
|
/// Low-quantity items whose total area is a small fraction of the plate are
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ namespace OpenNest.Engine
|
|||||||
var mbrArea = mbr.Area;
|
var mbrArea = mbr.Area;
|
||||||
var mbrPerimeter = 2 * (mbr.Width + mbr.Height);
|
var mbrPerimeter = 2 * (mbr.Width + mbr.Height);
|
||||||
|
|
||||||
// Store primary angle (negated to align MBR with axes, same as RotationAnalysis).
|
// Share the single angle formula with CanonicalAngle (no duplicate MBR compute).
|
||||||
result.PrimaryAngle = -mbr.Angle;
|
result.PrimaryAngle = CanonicalAngle.FromMbr(mbr);
|
||||||
|
|
||||||
// Drawing perimeter for circularity and perimeter ratio.
|
// Drawing perimeter for circularity and perimeter ratio.
|
||||||
var drawingPerimeter = polygon.Perimeter();
|
var drawingPerimeter = polygon.Perimeter();
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ namespace OpenNest.IO.Bending
|
|||||||
{
|
{
|
||||||
return document.Entities
|
return document.Entities
|
||||||
.OfType<ACadSharp.Entities.Line>()
|
.OfType<ACadSharp.Entities.Line>()
|
||||||
.Where(l => l.Layer?.Name == "BEND"
|
.Where(l => (l.Layer?.Name == "BEND" || l.Layer?.Name == "0")
|
||||||
&& (l.LineType?.Name?.Contains("CENTER") == true
|
&& (l.LineType?.Name?.Contains("CENTER") == true
|
||||||
|| l.LineType?.Name == "CENTERX2"))
|
|| l.LineType?.Name == "CENTERX2"))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using OpenNest.Bending;
|
|||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO.Bending;
|
using OpenNest.IO.Bending;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.IO
|
namespace OpenNest.IO
|
||||||
{
|
{
|
||||||
@@ -25,6 +26,8 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
var dxf = Dxf.Import(path);
|
var dxf = Dxf.Import(path);
|
||||||
|
|
||||||
|
RemoveDuplicateArcs(dxf.Entities);
|
||||||
|
|
||||||
var bends = new List<Bend>();
|
var bends = new List<Bend>();
|
||||||
if (options.DetectBends && dxf.Document != null)
|
if (options.DetectBends && dxf.Document != null)
|
||||||
{
|
{
|
||||||
@@ -136,5 +139,33 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
return drawing;
|
return drawing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static void RemoveDuplicateArcs(List<Entity> entities)
|
||||||
|
{
|
||||||
|
var circles = entities.OfType<Circle>().ToList();
|
||||||
|
var arcs = entities.OfType<Arc>().ToList();
|
||||||
|
var arcsToRemove = new List<Arc>();
|
||||||
|
|
||||||
|
foreach (var arc in arcs)
|
||||||
|
{
|
||||||
|
foreach (var circle in circles)
|
||||||
|
{
|
||||||
|
if (arc.Layer?.Name != circle.Layer?.Name)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!arc.Center.DistanceTo(circle.Center).IsEqualTo(0))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!arc.Radius.IsEqualTo(circle.Radius))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
arcsToRemove.Add(arc);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var arc in arcsToRemove)
|
||||||
|
entities.Remove(arc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,13 +181,22 @@ namespace OpenNest.IO
|
|||||||
{
|
{
|
||||||
var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
|
var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
|
||||||
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
|
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
|
||||||
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
|
|
||||||
var semiMinor = semiMajor * ellipse.RadiusRatio;
|
|
||||||
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
|
|
||||||
|
|
||||||
var startParam = ellipse.StartParameter;
|
var startParam = ellipse.StartParameter;
|
||||||
var endParam = ellipse.EndParameter;
|
var endParam = ellipse.EndParameter;
|
||||||
|
|
||||||
|
if (ellipse.Normal.Z < 0)
|
||||||
|
{
|
||||||
|
var newStart = OpenNest.Math.Angle.TwoPI - endParam;
|
||||||
|
var newEnd = OpenNest.Math.Angle.TwoPI - startParam;
|
||||||
|
startParam = newStart;
|
||||||
|
endParam = newEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
|
||||||
|
var semiMinor = semiMajor * ellipse.RadiusRatio;
|
||||||
|
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
|
||||||
|
|
||||||
var layer = ellipse.Layer.ToOpenNest();
|
var layer = ellipse.Layer.ToOpenNest();
|
||||||
var color = ellipse.ResolveColor();
|
var color = ellipse.ResolveColor();
|
||||||
var lineTypeName = ellipse.ResolveLineTypeName();
|
var lineTypeName = ellipse.ResolveLineTypeName();
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<RootNamespace>OpenNest.IO</RootNamespace>
|
<RootNamespace>OpenNest.IO</RootNamespace>
|
||||||
<AssemblyName>OpenNest.IO</AssemblyName>
|
<AssemblyName>OpenNest.IO</AssemblyName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CNC
|
||||||
|
{
|
||||||
|
public class RapidEnumeratorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Enumerate_AbsoluteProgram_OffsetsMotionsByBasePos()
|
||||||
|
{
|
||||||
|
var pgm = new Program(Mode.Absolute);
|
||||||
|
pgm.Codes.Add(new RapidMove(1, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(2, 0));
|
||||||
|
pgm.Codes.Add(new RapidMove(3, 3));
|
||||||
|
|
||||||
|
var segments = RapidEnumerator.Enumerate(pgm, basePos: new Vector(100, 200), startPos: new Vector(0, 0));
|
||||||
|
|
||||||
|
// Origin → first pierce, then interior rapid from contour end to next rapid target.
|
||||||
|
Assert.Equal(2, segments.Count);
|
||||||
|
Assert.Equal(new Vector(0, 0), segments[0].From);
|
||||||
|
Assert.Equal(new Vector(101, 200), segments[0].To);
|
||||||
|
Assert.Equal(new Vector(102, 200), segments[1].From);
|
||||||
|
Assert.Equal(new Vector(103, 203), segments[1].To);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Enumerate_IncrementalProgram_InterpretsDeltasFromBasePos()
|
||||||
|
{
|
||||||
|
// Pre-lead-in raw program: first rapid normalized to (0,0), Mode=Incremental
|
||||||
|
// (matches ConvertGeometry.ToProgram output).
|
||||||
|
var pgm = new Program(Mode.Incremental);
|
||||||
|
pgm.Codes.Add(new RapidMove(0, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(5, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(0, 5));
|
||||||
|
pgm.Codes.Add(new RapidMove(1, 1));
|
||||||
|
|
||||||
|
var segments = RapidEnumerator.Enumerate(pgm, basePos: new Vector(100, 200), startPos: new Vector(0, 0));
|
||||||
|
|
||||||
|
Assert.Equal(2, segments.Count);
|
||||||
|
// First rapid: plate origin → part pierce at basePos.
|
||||||
|
Assert.Equal(new Vector(0, 0), segments[0].From);
|
||||||
|
Assert.Equal(new Vector(100, 200), segments[0].To);
|
||||||
|
// Interior rapid: after deltas (5,0) and (0,5) from basePos, rapid delta (1,1).
|
||||||
|
Assert.Equal(new Vector(105, 205), segments[1].From);
|
||||||
|
Assert.Equal(new Vector(106, 206), segments[1].To);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Enumerate_SubProgramCall_RapidEndsAtAbsoluteHolePierce()
|
||||||
|
{
|
||||||
|
// Main program: lead-in rapid, a line, then a SubProgramCall for a hole.
|
||||||
|
// Sub-program (incremental) starts with RapidMove(radius, 0) to the hole pierce.
|
||||||
|
var sub = new Program(Mode.Incremental);
|
||||||
|
sub.Codes.Add(new RapidMove(0.5, 0));
|
||||||
|
sub.Codes.Add(new LinearMove(0, 0.1));
|
||||||
|
|
||||||
|
var pgm = new Program(Mode.Absolute);
|
||||||
|
pgm.Codes.Add(new RapidMove(0.2, 0.3)); // first pierce (perimeter lead-in)
|
||||||
|
pgm.Codes.Add(new LinearMove(1.0, 1.0)); // contour move
|
||||||
|
pgm.Codes.Add(new SubProgramCall
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Program = sub,
|
||||||
|
Offset = new Vector(2, 2), // hole center (drawing-local)
|
||||||
|
});
|
||||||
|
|
||||||
|
var basePos = new Vector(100, 200); // part.Location
|
||||||
|
var segments = RapidEnumerator.Enumerate(pgm, basePos, startPos: new Vector(0, 0));
|
||||||
|
|
||||||
|
// Expected rapids:
|
||||||
|
// 1. origin → first pierce (0.2+100, 0.3+200) = (100.2, 200.3)
|
||||||
|
// 2. end of contour (1+100, 1+200) = (101, 201) → hole pierce (2+100+0.5, 2+200) = (102.5, 202)
|
||||||
|
// The sub's internal first rapid is skipped (already drawn in #2).
|
||||||
|
Assert.Equal(2, segments.Count);
|
||||||
|
|
||||||
|
Assert.Equal(new Vector(0, 0), segments[0].From);
|
||||||
|
Assert.Equal(new Vector(100.2, 200.3), segments[0].To);
|
||||||
|
|
||||||
|
Assert.Equal(new Vector(101, 201), segments[1].From);
|
||||||
|
Assert.Equal(new Vector(102.5, 202), segments[1].To);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Engine;
|
||||||
|
|
||||||
|
public class CanonicalAngleTests
|
||||||
|
{
|
||||||
|
private const double AngleTol = 0.002; // ~0.11°
|
||||||
|
|
||||||
|
private static Drawing MakeRect(double w, double h)
|
||||||
|
{
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
return new Drawing("rect", pgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Drawing RotateCopy(Drawing src, double angle)
|
||||||
|
{
|
||||||
|
var pgm = src.Program.Clone() as OpenNest.CNC.Program;
|
||||||
|
pgm.Rotate(angle, pgm.BoundingBox().Center);
|
||||||
|
return new Drawing("rotated", pgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AxisAlignedRectangle_ReturnsZero()
|
||||||
|
{
|
||||||
|
var d = MakeRect(100, 50);
|
||||||
|
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Program.BoundingBox() has a pre-existing bug where minX/minY initialize to 0 and can
|
||||||
|
// only decrease, so programs whose extents stay in the positive half-plane report a
|
||||||
|
// too-large AABB. To validate MBR-axis-alignment without tripping that bug, extract the
|
||||||
|
// outer perimeter polygon and compute its true AABB from vertices.
|
||||||
|
private static (double length, double width) TrueAabb(OpenNest.CNC.Program pgm)
|
||||||
|
{
|
||||||
|
var entities = ConvertProgram.ToGeometry(pgm).Where(e => e.Layer != SpecialLayers.Rapid);
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
|
var outer = shapes.OrderByDescending(s => s.Area()).First();
|
||||||
|
var poly = outer.ToPolygonWithTolerance(0.1);
|
||||||
|
var minX = poly.Vertices.Min(v => v.X);
|
||||||
|
var maxX = poly.Vertices.Max(v => v.X);
|
||||||
|
var minY = poly.Vertices.Min(v => v.Y);
|
||||||
|
var maxY = poly.Vertices.Max(v => v.Y);
|
||||||
|
return (maxX - minX, maxY - minY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.3)]
|
||||||
|
[InlineData(0.7)]
|
||||||
|
[InlineData(1.2)]
|
||||||
|
public void Rectangle_ReturnsNegatedRotation_Modulo90(double theta)
|
||||||
|
{
|
||||||
|
var rotated = RotateCopy(MakeRect(100, 50), theta);
|
||||||
|
var angle = CanonicalAngle.Compute(rotated);
|
||||||
|
|
||||||
|
// Applying the returned angle should leave MBR axis-aligned.
|
||||||
|
var canonical = rotated.Program.Clone() as OpenNest.CNC.Program;
|
||||||
|
canonical.Rotate(angle, canonical.BoundingBox().Center);
|
||||||
|
|
||||||
|
var (length, width) = TrueAabb(canonical);
|
||||||
|
var longer = System.Math.Max(length, width);
|
||||||
|
var shorter = System.Math.Min(length, width);
|
||||||
|
Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
|
||||||
|
Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NearZeroInput_SnapsToZero()
|
||||||
|
{
|
||||||
|
var rotated = RotateCopy(MakeRect(100, 50), 0.0005);
|
||||||
|
Assert.Equal(0.0, CanonicalAngle.Compute(rotated), precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DegeneratePolygon_ReturnsZero()
|
||||||
|
{
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
|
||||||
|
var d = new Drawing("line", pgm);
|
||||||
|
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyProgram_ReturnsZero()
|
||||||
|
{
|
||||||
|
var d = new Drawing("empty", new OpenNest.CNC.Program());
|
||||||
|
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DrawingCanonicalAngleWiringTests
|
||||||
|
{
|
||||||
|
private static OpenNest.CNC.Program RotatedRectProgram(double w, double h, double theta)
|
||||||
|
{
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
if (!OpenNest.Math.Tolerance.IsEqualTo(theta, 0))
|
||||||
|
pgm.Rotate(theta, pgm.BoundingBox().Center);
|
||||||
|
return pgm;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_ComputesAngleOnProgramAssignment()
|
||||||
|
{
|
||||||
|
var pgm = RotatedRectProgram(100, 50, 0.5);
|
||||||
|
var d = new Drawing("r", pgm);
|
||||||
|
Assert.InRange(d.Source.Angle, -0.52, -0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetProgram_RecomputesAngle()
|
||||||
|
{
|
||||||
|
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
|
||||||
|
Assert.Equal(0.0, d.Source.Angle, precision: 6);
|
||||||
|
|
||||||
|
d.Program = RotatedRectProgram(100, 50, 0.5);
|
||||||
|
Assert.InRange(d.Source.Angle, -0.52, -0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsCutOff_SkipsAngleComputation()
|
||||||
|
{
|
||||||
|
var d = new Drawing("cut", RotatedRectProgram(100, 50, 0.5)) { IsCutOff = true };
|
||||||
|
// Re-assign after flag is set so the setter observes IsCutOff.
|
||||||
|
d.Program = RotatedRectProgram(100, 50, 0.5);
|
||||||
|
Assert.Equal(0.0, d.Source.Angle, precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeCanonicalAngle_UpdatesAfterMutation()
|
||||||
|
{
|
||||||
|
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
|
||||||
|
Assert.Equal(0.0, d.Source.Angle, precision: 6);
|
||||||
|
|
||||||
|
// Mutate in-place (doesn't trigger setter).
|
||||||
|
d.Program.Rotate(0.5, d.Program.BoundingBox().Center);
|
||||||
|
Assert.Equal(0.0, d.Source.Angle, precision: 6); // still stale
|
||||||
|
|
||||||
|
d.RecomputeCanonicalAngle();
|
||||||
|
Assert.InRange(d.Source.Angle, -0.52, -0.48);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Engine;
|
||||||
|
|
||||||
|
public class CanonicalFrameTests
|
||||||
|
{
|
||||||
|
private static Drawing MakeRect(double w, double h, double rotation)
|
||||||
|
{
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
if (!Tolerance.IsEqualTo(rotation, 0))
|
||||||
|
pgm.Rotate(rotation, pgm.BoundingBox().Center);
|
||||||
|
return new Drawing("rect", pgm) { Source = new SourceInfo { Angle = -rotation } };
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AsCanonicalCopy_AxisAlignsMbr()
|
||||||
|
{
|
||||||
|
var d = MakeRect(100, 50, 0.6);
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(d);
|
||||||
|
|
||||||
|
var bb = canonical.Program.BoundingBox();
|
||||||
|
var longer = System.Math.Max(bb.Length, bb.Width);
|
||||||
|
var shorter = System.Math.Min(bb.Length, bb.Width);
|
||||||
|
Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
|
||||||
|
Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
|
||||||
|
Assert.Equal(0.0, canonical.Source.Angle, precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AsCanonicalCopy_DoesNotMutateSource()
|
||||||
|
{
|
||||||
|
var d = MakeRect(100, 50, 0.6);
|
||||||
|
var originalBbox = d.Program.BoundingBox();
|
||||||
|
var originalAngle = d.Source.Angle;
|
||||||
|
|
||||||
|
CanonicalFrame.AsCanonicalCopy(d);
|
||||||
|
|
||||||
|
var afterBbox = d.Program.BoundingBox();
|
||||||
|
Assert.Equal(originalBbox.Width, afterBbox.Width, precision: 6);
|
||||||
|
Assert.Equal(originalBbox.Length, afterBbox.Length, precision: 6);
|
||||||
|
Assert.Equal(originalAngle, d.Source.Angle, precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromCanonical_ComposesSourceAngleOntoRotation()
|
||||||
|
{
|
||||||
|
var d = MakeRect(100, 50, 0.0);
|
||||||
|
var part = new Part(d);
|
||||||
|
part.Rotate(0.2); // engine returned a canonical-frame part at R = 0.2
|
||||||
|
|
||||||
|
var placed = CanonicalFrame.FromCanonical(new List<Part> { part }, sourceAngle: -0.5);
|
||||||
|
|
||||||
|
// R' = R + sourceAngle = 0.2 + (-0.5) = -0.3
|
||||||
|
// Part.Rotation comes from Program.Rotation which is normalized to [0, 2PI),
|
||||||
|
// so compare after normalizing the expected value as well.
|
||||||
|
Assert.Single(placed);
|
||||||
|
Assert.Equal(Angle.NormalizeRad(-0.3), placed[0].Rotation, precision: 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_RestoresGeometry()
|
||||||
|
{
|
||||||
|
var d = MakeRect(100, 50, 0.4);
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(d);
|
||||||
|
|
||||||
|
// Place a part at origin in the canonical frame.
|
||||||
|
var part = Part.CreateAtOrigin(canonical);
|
||||||
|
var canonicalBbox = part.BoundingBox;
|
||||||
|
|
||||||
|
var placed = CanonicalFrame.FromCanonical(new List<Part> { part }, d.Source.Angle);
|
||||||
|
|
||||||
|
var originalBbox = d.Program.BoundingBox();
|
||||||
|
Assert.Equal(originalBbox.Width, placed[0].BoundingBox.Width, precision: 2);
|
||||||
|
Assert.Equal(originalBbox.Length, placed[0].BoundingBox.Length, precision: 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Engine;
|
||||||
|
|
||||||
|
public class NestInvarianceTests
|
||||||
|
{
|
||||||
|
private static OpenNest.CNC.Program MakeLShapedProgram()
|
||||||
|
{
|
||||||
|
// L-shape: 100x50 outer rect with a 50x30 notch removed from top-right.
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(100, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(100, 20)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(50, 20)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(50, 50)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 50)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
return pgm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Drawing MakeImportedAt(double rotation)
|
||||||
|
{
|
||||||
|
var pgm = MakeLShapedProgram();
|
||||||
|
if (!Tolerance.IsEqualTo(rotation, 0))
|
||||||
|
pgm.Rotate(rotation, pgm.BoundingBox().Center);
|
||||||
|
return new Drawing("L", pgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Plate MakePlate() => new Plate(new Size(500, 500))
|
||||||
|
{
|
||||||
|
Quadrant = 1,
|
||||||
|
PartSpacing = 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static int RunFillCount(Drawing drawing, Plate plate)
|
||||||
|
{
|
||||||
|
BestFitCache.Clear();
|
||||||
|
var engine = new DefaultNestEngine(plate);
|
||||||
|
var item = new NestItem { Drawing = drawing };
|
||||||
|
var parts = engine.Fill(item, plate.WorkArea(), progress: null, token: CancellationToken.None);
|
||||||
|
return parts?.Count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.0)]
|
||||||
|
[InlineData(0.3)]
|
||||||
|
[InlineData(0.8)]
|
||||||
|
[InlineData(1.2)]
|
||||||
|
public void Fill_SameCount_AcrossImportOrientations(double theta)
|
||||||
|
{
|
||||||
|
var baseline = RunFillCount(MakeImportedAt(0.0), MakePlate());
|
||||||
|
var rotated = RunFillCount(MakeImportedAt(theta), MakePlate());
|
||||||
|
|
||||||
|
// Allow +/-1 tolerance for sweep quantization edge effects near plate boundaries.
|
||||||
|
Assert.InRange(rotated, baseline - 1, baseline + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fill_PlacedPartsStayWithinWorkArea_AcrossImportOrientations()
|
||||||
|
{
|
||||||
|
var plate = MakePlate();
|
||||||
|
var workArea = plate.WorkArea();
|
||||||
|
|
||||||
|
foreach (var theta in new[] { 0.0, 0.3, 0.8, 1.2 })
|
||||||
|
{
|
||||||
|
BestFitCache.Clear();
|
||||||
|
var engine = new DefaultNestEngine(plate);
|
||||||
|
var item = new NestItem { Drawing = MakeImportedAt(theta) };
|
||||||
|
var parts = engine.Fill(item, workArea, progress: null, token: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(parts);
|
||||||
|
foreach (var p in parts)
|
||||||
|
{
|
||||||
|
Assert.InRange(p.BoundingBox.Left, workArea.Left - 0.5, workArea.Right + 0.5);
|
||||||
|
Assert.InRange(p.BoundingBox.Bottom, workArea.Bottom - 0.5, workArea.Top + 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,76 @@ namespace OpenNest.Tests.Fill
|
|||||||
{
|
{
|
||||||
public class CompactorTests
|
public class CompactorTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DirectionalDistance_ArcVsInclinedLine_DoesNotOverPush()
|
||||||
|
{
|
||||||
|
// Arc (top semicircle) pushed upward toward a 45° inclined line.
|
||||||
|
// The critical angle on the arc gives a shorter distance than any
|
||||||
|
// sampled vertex (endpoints + cardinal extremes).
|
||||||
|
var arc = new Arc(5, 0, 2, 0, System.Math.PI);
|
||||||
|
var line = new Line(new Vector(3, 4), new Vector(7, 6));
|
||||||
|
|
||||||
|
var moving = new List<Entity> { arc };
|
||||||
|
var stationary = new List<Entity> { line };
|
||||||
|
var direction = new Vector(0, 1); // push up
|
||||||
|
|
||||||
|
var dist = SpatialQuery.DirectionalDistance(moving, stationary, direction);
|
||||||
|
|
||||||
|
// Move the arc up by the computed distance, then verify no overlap.
|
||||||
|
// The topmost reachable point on the arc at the critical angle θ ≈ 2.034
|
||||||
|
// (between π/2 and π) should just touch the line.
|
||||||
|
Assert.True(dist < double.MaxValue, "Should find a finite distance");
|
||||||
|
Assert.True(dist > 0, "Should be a positive distance");
|
||||||
|
|
||||||
|
// Verify: after moving, the closest point on the arc should be within
|
||||||
|
// tolerance of the line, not past it.
|
||||||
|
var theta = System.Math.Atan2(
|
||||||
|
line.pt2.X - line.pt1.X, -(line.pt2.Y - line.pt1.Y));
|
||||||
|
theta = OpenNest.Math.Angle.NormalizeRad(theta + System.Math.PI);
|
||||||
|
var qx = arc.Center.X + arc.Radius * System.Math.Cos(theta);
|
||||||
|
var qy = arc.Center.Y + arc.Radius * System.Math.Sin(theta) + dist;
|
||||||
|
|
||||||
|
// The moved point should be on or just touching the line, not past it.
|
||||||
|
// Line equation: (y - 4) / (x - 3) = (6 - 4) / (7 - 3) = 0.5
|
||||||
|
// y = 0.5x + 2.5
|
||||||
|
var lineYAtQx = 0.5 * qx + 2.5;
|
||||||
|
Assert.True(qy <= lineYAtQx + 0.001,
|
||||||
|
$"Arc point ({qx:F4}, {qy:F4}) should not be past line (line Y={lineYAtQx:F4} at X={qx:F4}). " +
|
||||||
|
$"dist={dist:F6}, overshot by {qy - lineYAtQx:F6}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectionalDistance_ArcVsInclinedLine_BetterThanVertexSampling()
|
||||||
|
{
|
||||||
|
// Same geometry — verify the analytical Phase 3 finds a shorter
|
||||||
|
// distance than the Phase 1/2 vertex sampling alone would.
|
||||||
|
var arc = new Arc(5, 0, 2, 0, System.Math.PI);
|
||||||
|
var line = new Line(new Vector(3, 4), new Vector(7, 6));
|
||||||
|
|
||||||
|
// Phase 1/2 vertex-only distance: sample arc endpoints + cardinal extreme.
|
||||||
|
var vertices = new[]
|
||||||
|
{
|
||||||
|
new Vector(7, 0), // arc endpoint θ=0
|
||||||
|
new Vector(3, 0), // arc endpoint θ=π
|
||||||
|
new Vector(5, 2), // cardinal extreme θ=π/2
|
||||||
|
};
|
||||||
|
|
||||||
|
var vertexMin = double.MaxValue;
|
||||||
|
foreach (var v in vertices)
|
||||||
|
{
|
||||||
|
var d = SpatialQuery.RayEdgeDistance(v.X, v.Y,
|
||||||
|
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y, 0, 1);
|
||||||
|
if (d < vertexMin) vertexMin = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full directional distance (includes Phase 3 arc-to-line).
|
||||||
|
var moving = new List<Entity> { arc };
|
||||||
|
var stationary = new List<Entity> { line };
|
||||||
|
var fullDist = SpatialQuery.DirectionalDistance(moving, stationary, new Vector(0, 1));
|
||||||
|
|
||||||
|
Assert.True(fullDist < vertexMin,
|
||||||
|
$"Full distance ({fullDist:F6}) should be less than vertex-only ({vertexMin:F6})");
|
||||||
|
}
|
||||||
private static Drawing MakeRectDrawing(double w, double h)
|
private static Drawing MakeRectDrawing(double w, double h)
|
||||||
{
|
{
|
||||||
var pgm = new OpenNest.CNC.Program();
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Geometry;
|
||||||
|
|
||||||
|
public class BoxComparisonTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GreaterThan_TallerBox_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var tall = new Box(0, 0, 10, 20);
|
||||||
|
var short_ = new Box(0, 0, 10, 10);
|
||||||
|
|
||||||
|
Assert.True(tall > short_);
|
||||||
|
Assert.False(short_ > tall);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GreaterThan_SameWidthLongerBox_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var longer = new Box(0, 0, 20, 10);
|
||||||
|
var shorter = new Box(0, 0, 10, 10);
|
||||||
|
|
||||||
|
Assert.True(longer > shorter);
|
||||||
|
Assert.False(shorter > longer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LessThan_ShorterBox_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var tall = new Box(0, 0, 10, 20);
|
||||||
|
var short_ = new Box(0, 0, 10, 10);
|
||||||
|
|
||||||
|
Assert.True(short_ < tall);
|
||||||
|
Assert.False(tall < short_);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GreaterThanOrEqual_EqualBoxes_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var a = new Box(0, 0, 10, 20);
|
||||||
|
var b = new Box(0, 0, 10, 20);
|
||||||
|
|
||||||
|
Assert.True(a >= b);
|
||||||
|
Assert.True(b >= a);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LessThanOrEqual_EqualBoxes_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var a = new Box(0, 0, 10, 20);
|
||||||
|
var b = new Box(0, 0, 10, 20);
|
||||||
|
|
||||||
|
Assert.True(a <= b);
|
||||||
|
Assert.True(b <= a);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompareTo_TallerBox_ReturnsPositive()
|
||||||
|
{
|
||||||
|
var tall = new Box(0, 0, 10, 20);
|
||||||
|
var short_ = new Box(0, 0, 10, 10);
|
||||||
|
|
||||||
|
Assert.True(tall.CompareTo(short_) > 0);
|
||||||
|
Assert.True(short_.CompareTo(tall) < 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompareTo_EqualBoxes_ReturnsZero()
|
||||||
|
{
|
||||||
|
var a = new Box(0, 0, 10, 20);
|
||||||
|
var b = new Box(0, 0, 10, 20);
|
||||||
|
|
||||||
|
Assert.Equal(0, a.CompareTo(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sort_OrdersByWidthThenLength()
|
||||||
|
{
|
||||||
|
var boxes = new List<Box>
|
||||||
|
{
|
||||||
|
new Box(0, 0, 20, 10),
|
||||||
|
new Box(0, 0, 5, 30),
|
||||||
|
new Box(0, 0, 10, 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
boxes.Sort();
|
||||||
|
|
||||||
|
Assert.Equal(10, boxes[0].Width);
|
||||||
|
Assert.Equal(10, boxes[0].Length);
|
||||||
|
Assert.Equal(10, boxes[1].Width);
|
||||||
|
Assert.Equal(20, boxes[1].Length);
|
||||||
|
Assert.Equal(30, boxes[2].Width);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
namespace OpenNest.Tests.Geometry;
|
namespace OpenNest.Tests.Geometry;
|
||||||
|
|
||||||
public class EllipseConverterTests
|
public class EllipseConverterTests
|
||||||
{
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
private const double Tol = 1e-10;
|
private const double Tol = 1e-10;
|
||||||
|
|
||||||
|
public EllipseConverterTests(ITestOutputHelper output) => _output = output;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd()
|
public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd()
|
||||||
{
|
{
|
||||||
@@ -244,6 +249,101 @@ public class EllipseConverterTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DxfImport_ArcBoundingBoxes_Diagnostic()
|
||||||
|
{
|
||||||
|
var path = @"C:\Users\aisaacs\Desktop\11ga tab.dxf";
|
||||||
|
if (!System.IO.File.Exists(path)) return;
|
||||||
|
|
||||||
|
var result = Dxf.Import(path);
|
||||||
|
var all = (System.Collections.Generic.IEnumerable<IBoundable>)result.Entities;
|
||||||
|
var bbox = all.GetBoundingBox();
|
||||||
|
_output.WriteLine($"Overall: X={bbox.X:F4} Y={bbox.Y:F4} W={bbox.Length:F4} H={bbox.Width:F4}");
|
||||||
|
|
||||||
|
for (var i = 0; i < result.Entities.Count; i++)
|
||||||
|
{
|
||||||
|
var e = result.Entities[i];
|
||||||
|
var b = e.BoundingBox;
|
||||||
|
var flag = (b.Length > 1 || b.Width > 1) ? " ***" : "";
|
||||||
|
_output.WriteLine($"{i + 1,3}. {e.GetType().Name,-8} X={b.X:F4} Y={b.Y:F4} W={b.Length:F4} H={b.Width:F4}{flag}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToOpenNest_FlippedNormalZ_ProducesCorrectArcs()
|
||||||
|
{
|
||||||
|
var normal = new ACadSharp.Entities.Ellipse
|
||||||
|
{
|
||||||
|
Center = new CSMath.XYZ(-0.275, -0.245, 0),
|
||||||
|
MajorAxisEndPoint = new CSMath.XYZ(0.0001, 1.245, 0),
|
||||||
|
RadiusRatio = 0.28,
|
||||||
|
StartParameter = 0.017,
|
||||||
|
EndParameter = 1.571,
|
||||||
|
Normal = new CSMath.XYZ(0, 0, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
var flipped = new ACadSharp.Entities.Ellipse
|
||||||
|
{
|
||||||
|
Center = new CSMath.XYZ(0.275, -0.245, 0),
|
||||||
|
MajorAxisEndPoint = new CSMath.XYZ(-0.0001, 1.245, 0),
|
||||||
|
RadiusRatio = 0.28,
|
||||||
|
StartParameter = 0.017,
|
||||||
|
EndParameter = 1.571,
|
||||||
|
Normal = new CSMath.XYZ(0, 0, -1)
|
||||||
|
};
|
||||||
|
|
||||||
|
var normalArcs = normal.ToOpenNest();
|
||||||
|
var flippedArcs = flipped.ToOpenNest();
|
||||||
|
|
||||||
|
Assert.True(normalArcs.Count > 0);
|
||||||
|
Assert.True(flippedArcs.Count > 0);
|
||||||
|
Assert.True(normalArcs.All(e => e is Arc));
|
||||||
|
Assert.True(flippedArcs.All(e => e is Arc));
|
||||||
|
|
||||||
|
var normalFirst = (Arc)normalArcs.First();
|
||||||
|
var flippedFirst = (Arc)flippedArcs.First();
|
||||||
|
var normalStart = GetArcStart(normalFirst);
|
||||||
|
var flippedStart = GetArcStart(flippedFirst);
|
||||||
|
|
||||||
|
Assert.True(normalStart.X < 0, $"Normal ellipse start X should be negative, got {normalStart.X}");
|
||||||
|
Assert.True(flippedStart.X > 0, $"Flipped ellipse should bulge right, got {flippedStart.X}");
|
||||||
|
|
||||||
|
var normalBbox = GetBoundingBox(normalArcs.Cast<Arc>());
|
||||||
|
var flippedBbox = GetBoundingBox(flippedArcs.Cast<Arc>());
|
||||||
|
Assert.True(flippedBbox.minX > 0, $"Flipped ellipse should stay on positive X side, minX={flippedBbox.minX}");
|
||||||
|
Assert.True(normalBbox.maxX < 0, $"Normal ellipse should stay on negative X side, maxX={normalBbox.maxX}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (double minX, double maxX) GetBoundingBox(IEnumerable<Arc> arcs)
|
||||||
|
{
|
||||||
|
var minX = double.MaxValue;
|
||||||
|
var maxX = double.MinValue;
|
||||||
|
foreach (var arc in arcs)
|
||||||
|
{
|
||||||
|
var s = GetArcStart(arc);
|
||||||
|
var e = GetArcEnd(arc);
|
||||||
|
minX = System.Math.Min(minX, System.Math.Min(s.X, e.X));
|
||||||
|
maxX = System.Math.Max(maxX, System.Math.Max(s.X, e.X));
|
||||||
|
}
|
||||||
|
return (minX, maxX);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector GetArcStart(Arc arc)
|
||||||
|
{
|
||||||
|
var angle = arc.IsReversed ? arc.EndAngle : arc.StartAngle;
|
||||||
|
return new Vector(
|
||||||
|
arc.Center.X + arc.Radius * System.Math.Cos(angle),
|
||||||
|
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector GetArcEnd(Arc arc)
|
||||||
|
{
|
||||||
|
var angle = arc.IsReversed ? arc.StartAngle : arc.EndAngle;
|
||||||
|
return new Vector(
|
||||||
|
arc.Center.X + arc.Radius * System.Math.Cos(angle),
|
||||||
|
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
|
||||||
|
}
|
||||||
|
|
||||||
private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter,
|
private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter,
|
||||||
double semiMajor, double semiMinor, double rotation, int samples)
|
double semiMajor, double semiMinor, double rotation, int samples)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Geometry;
|
||||||
|
|
||||||
|
public class WeldEndpointsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void WeldEndpoints_SnapsNearbyLineEndpoints()
|
||||||
|
{
|
||||||
|
var line1 = new Line(0, 0, 10, 0);
|
||||||
|
var line2 = new Line(10.0000005, 0, 20, 0);
|
||||||
|
var entities = new List<Entity> { line1, line2 };
|
||||||
|
|
||||||
|
ShapeBuilder.WeldEndpoints(entities, 0.000001);
|
||||||
|
|
||||||
|
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) <= Tolerance.Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeldEndpoints_SnapsArcEndpointByAdjustingAngle()
|
||||||
|
{
|
||||||
|
var line = new Line(0, 0, 10, 0);
|
||||||
|
var arc = new Arc(15, 0, 5, Angle.ToRadians(180.001), Angle.ToRadians(90));
|
||||||
|
var entities = new List<Entity> { line, arc };
|
||||||
|
|
||||||
|
ShapeBuilder.WeldEndpoints(entities, 0.01);
|
||||||
|
|
||||||
|
var arcStart = arc.StartPoint();
|
||||||
|
Assert.True(line.EndPoint.DistanceTo(arcStart) <= 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeldEndpoints_DoesNotWeldDistantEndpoints()
|
||||||
|
{
|
||||||
|
var line1 = new Line(0, 0, 10, 0);
|
||||||
|
var line2 = new Line(10.1, 0, 20, 0);
|
||||||
|
var entities = new List<Entity> { line1, line2 };
|
||||||
|
|
||||||
|
ShapeBuilder.WeldEndpoints(entities, 0.000001);
|
||||||
|
|
||||||
|
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) > 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetShapes_WithWeldTolerance_WeldsBeforeChaining()
|
||||||
|
{
|
||||||
|
var line1 = new Line(0, 0, 10, 0);
|
||||||
|
var line2 = new Line(10.0000005, 0, 10.0000005, 10);
|
||||||
|
var entities = new List<Entity> { line1, line2 };
|
||||||
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities, weldTolerance: 0.000001);
|
||||||
|
|
||||||
|
Assert.Single(shapes);
|
||||||
|
Assert.Equal(2, shapes[0].Entities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetShapes_WithoutWeldTolerance_DefaultBehavior()
|
||||||
|
{
|
||||||
|
var line1 = new Line(0, 0, 10, 0);
|
||||||
|
var line2 = new Line(10, 0, 10, 10);
|
||||||
|
var entities = new List<Entity> { line1, line2 };
|
||||||
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
|
|
||||||
|
Assert.Single(shapes);
|
||||||
|
Assert.Equal(2, shapes[0].Entities.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.IO;
|
||||||
|
|
||||||
|
public class RemoveDuplicateArcsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_RemovesArcMatchingCircle_SameLayer()
|
||||||
|
{
|
||||||
|
var layer = new Layer("0");
|
||||||
|
var circle = new Circle(10, 10, 5) { Layer = layer };
|
||||||
|
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
|
||||||
|
var line = new Line(0, 0, 10, 0) { Layer = layer };
|
||||||
|
var entities = new List<Entity> { circle, arc, line };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Equal(2, entities.Count);
|
||||||
|
Assert.Contains(circle, entities);
|
||||||
|
Assert.Contains(line, entities);
|
||||||
|
Assert.DoesNotContain(arc, entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_KeepsArcOnDifferentLayer()
|
||||||
|
{
|
||||||
|
var layer1 = new Layer("cut");
|
||||||
|
var layer2 = new Layer("etch");
|
||||||
|
var circle = new Circle(10, 10, 5) { Layer = layer1 };
|
||||||
|
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer2 };
|
||||||
|
var entities = new List<Entity> { circle, arc };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Equal(2, entities.Count);
|
||||||
|
Assert.Contains(arc, entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_KeepsArcWithDifferentRadius()
|
||||||
|
{
|
||||||
|
var layer = new Layer("0");
|
||||||
|
var circle = new Circle(10, 10, 5) { Layer = layer };
|
||||||
|
var arc = new Arc(10, 10, 3, 0, Angle.ToRadians(90)) { Layer = layer };
|
||||||
|
var entities = new List<Entity> { circle, arc };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Equal(2, entities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_KeepsArcWithDifferentCenter()
|
||||||
|
{
|
||||||
|
var layer = new Layer("0");
|
||||||
|
var circle = new Circle(10, 10, 5) { Layer = layer };
|
||||||
|
var arc = new Arc(20, 20, 5, 0, Angle.ToRadians(90)) { Layer = layer };
|
||||||
|
var entities = new List<Entity> { circle, arc };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Equal(2, entities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_NoCircles_NoChange()
|
||||||
|
{
|
||||||
|
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90));
|
||||||
|
var line = new Line(0, 0, 10, 0);
|
||||||
|
var entities = new List<Entity> { arc, line };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Equal(2, entities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_MultipleArcsMatchOneCircle_RemovesAll()
|
||||||
|
{
|
||||||
|
var layer = new Layer("0");
|
||||||
|
var circle = new Circle(10, 10, 5) { Layer = layer };
|
||||||
|
var arc1 = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
|
||||||
|
var arc2 = new Arc(10, 10, 5, Angle.ToRadians(90), Angle.ToRadians(180)) { Layer = layer };
|
||||||
|
var entities = new List<Entity> { circle, arc1, arc2 };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Single(entities);
|
||||||
|
Assert.Contains(circle, entities);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using OpenNest.Math;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Math;
|
||||||
|
|
||||||
|
public class FractionTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("3/8", 0.375)]
|
||||||
|
[InlineData("1 3/4", 1.75)]
|
||||||
|
[InlineData("1-3/4", 1.75)]
|
||||||
|
[InlineData("1/2", 0.5)]
|
||||||
|
public void Parse_ValidFraction_ReturnsDouble(string input, double expected)
|
||||||
|
{
|
||||||
|
var result = Fraction.Parse(input);
|
||||||
|
|
||||||
|
Assert.Equal(expected, result, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("3/8", true)]
|
||||||
|
[InlineData("abc", false)]
|
||||||
|
[InlineData("1 3/4", true)]
|
||||||
|
public void IsValid_ReturnsExpected(string input, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, Fraction.IsValid(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_InvalidInput_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var result = Fraction.TryParse("abc", out var value);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
Assert.Equal(0, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReplaceFractionsWithDecimals_ReplacesFractionInString()
|
||||||
|
{
|
||||||
|
var result = Fraction.ReplaceFractionsWithDecimals("length is 1 3/4 inches");
|
||||||
|
|
||||||
|
Assert.Contains("1.75", result);
|
||||||
|
Assert.DoesNotContain("3/4", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@
|
|||||||
<Content Include="Bending\TestData\**\*">
|
<Content Include="Bending\TestData\**\*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="Splitting\TestData\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
|
public class PlateSnapToStandardSizeTests
|
||||||
|
{
|
||||||
|
private static Part MakeRectPart(double x, double y, double length, double width)
|
||||||
|
{
|
||||||
|
var pgm = new Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(length, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(length, width)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, width)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
var drawing = new Drawing("test", pgm);
|
||||||
|
var part = new Part(drawing);
|
||||||
|
part.Offset(x, y);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_SmallParts_SnapsToIncrement()
|
||||||
|
{
|
||||||
|
var plate = new Plate(200, 200); // oversized starting size
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 10, 20));
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
// 10x20 is well below 48x48 MinSheet -> snap to integer increment.
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
Assert.Equal(10, plate.Size.Length); // X axis
|
||||||
|
Assert.Equal(20, plate.Size.Width); // Y axis
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_SmallPartsWithFractionalIncrement_UsesIncrement()
|
||||||
|
{
|
||||||
|
var plate = new Plate(200, 200);
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 10.3, 20.7));
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize(new PlateSizeOptions { SnapIncrement = 0.25 });
|
||||||
|
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
Assert.Equal(10.5, plate.Size.Length, 4);
|
||||||
|
Assert.Equal(20.75, plate.Size.Width, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_40x90Part_SnapsToStandard48x96_XLong()
|
||||||
|
{
|
||||||
|
// Part is 90 long (X) x 40 wide (Y) -> X is the long axis.
|
||||||
|
var plate = new Plate(200, 200);
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 90, 40));
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
Assert.Equal(96, plate.Size.Length); // X axis = long
|
||||||
|
Assert.Equal(48, plate.Size.Width); // Y axis = short
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_90TallPart_SnapsToStandard48x96_YLong()
|
||||||
|
{
|
||||||
|
// Part is 40 long (X) x 90 wide (Y) -> Y is the long axis.
|
||||||
|
var plate = new Plate(200, 200);
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 40, 90));
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
Assert.Equal(48, plate.Size.Length); // X axis = short
|
||||||
|
Assert.Equal(96, plate.Size.Width); // Y axis = long
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_JustOver48_PicksNextStandardSize()
|
||||||
|
{
|
||||||
|
var plate = new Plate(200, 200);
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 100, 50));
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
Assert.Equal("60x120", result.MatchedLabel);
|
||||||
|
Assert.Equal(120, plate.Size.Length); // X long
|
||||||
|
Assert.Equal(60, plate.Size.Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_EmptyPlate_DoesNotModifySize()
|
||||||
|
{
|
||||||
|
var plate = new Plate(60, 120);
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
Assert.Equal(60, plate.Size.Width);
|
||||||
|
Assert.Equal(120, plate.Size.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_MultipleParts_UsesCombinedEnvelope()
|
||||||
|
{
|
||||||
|
var plate = new Plate(200, 200);
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 30, 40));
|
||||||
|
plate.Parts.Add(MakeRectPart(30, 0, 30, 40)); // combined X-extent = 60
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 40, 60, 60)); // combined extent = 60 x 100
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
// 60 x 100 fits 60x120 standard sheet, Y is the long axis.
|
||||||
|
Assert.Equal("60x120", result.MatchedLabel);
|
||||||
|
Assert.Equal(60, plate.Size.Length); // X
|
||||||
|
Assert.Equal(120, plate.Size.Width); // Y long
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
using OpenNest.Shapes;
|
|
||||||
|
|
||||||
namespace OpenNest.Tests.Shapes;
|
|
||||||
|
|
||||||
public class FlangeShapeTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void GetDrawing_BoundingBoxMatchesOD()
|
|
||||||
{
|
|
||||||
var shape = new FlangeShape
|
|
||||||
{
|
|
||||||
OD = 10,
|
|
||||||
HoleDiameter = 1,
|
|
||||||
HolePatternDiameter = 7,
|
|
||||||
HoleCount = 4
|
|
||||||
};
|
|
||||||
var drawing = shape.GetDrawing();
|
|
||||||
|
|
||||||
var bbox = drawing.Program.BoundingBox();
|
|
||||||
Assert.Equal(10, bbox.Width, 0.01);
|
|
||||||
Assert.Equal(10, bbox.Length, 0.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetDrawing_AreaExcludesBoltHoles()
|
|
||||||
{
|
|
||||||
var shape = new FlangeShape
|
|
||||||
{
|
|
||||||
OD = 10,
|
|
||||||
HoleDiameter = 1,
|
|
||||||
HolePatternDiameter = 7,
|
|
||||||
HoleCount = 4
|
|
||||||
};
|
|
||||||
var drawing = shape.GetDrawing();
|
|
||||||
|
|
||||||
// Area = pi * 5^2 - 4 * pi * 0.5^2 = pi * (25 - 1) = pi * 24
|
|
||||||
var expectedArea = System.Math.PI * 24;
|
|
||||||
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetDrawing_DefaultName_IsFlange()
|
|
||||||
{
|
|
||||||
var shape = new FlangeShape
|
|
||||||
{
|
|
||||||
OD = 10,
|
|
||||||
HoleDiameter = 1,
|
|
||||||
HolePatternDiameter = 7,
|
|
||||||
HoleCount = 4
|
|
||||||
};
|
|
||||||
var drawing = shape.GetDrawing();
|
|
||||||
|
|
||||||
Assert.Equal("Flange", drawing.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void LoadFromJson_ProducesCorrectDrawing()
|
|
||||||
{
|
|
||||||
var json = """
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"Name": "2in-150#",
|
|
||||||
"NominalPipeSize": 2.0,
|
|
||||||
"OD": 6.0,
|
|
||||||
"HoleDiameter": 0.75,
|
|
||||||
"HolePatternDiameter": 4.75,
|
|
||||||
"HoleCount": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "2in-300#",
|
|
||||||
"NominalPipeSize": 2.0,
|
|
||||||
"OD": 6.5,
|
|
||||||
"HoleDiameter": 0.75,
|
|
||||||
"HolePatternDiameter": 5.0,
|
|
||||||
"HoleCount": 8
|
|
||||||
}
|
|
||||||
]
|
|
||||||
""";
|
|
||||||
|
|
||||||
var tempFile = Path.GetTempFileName();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.WriteAllText(tempFile, json);
|
|
||||||
|
|
||||||
var flanges = ShapeDefinition.LoadFromJson<FlangeShape>(tempFile);
|
|
||||||
|
|
||||||
Assert.Equal(2, flanges.Count);
|
|
||||||
|
|
||||||
var first = flanges[0];
|
|
||||||
Assert.Equal("2in-150#", first.Name);
|
|
||||||
var drawing = first.GetDrawing();
|
|
||||||
var bbox = drawing.Program.BoundingBox();
|
|
||||||
Assert.Equal(6, bbox.Width, 0.01);
|
|
||||||
|
|
||||||
var second = flanges[1];
|
|
||||||
Assert.Equal("2in-300#", second.Name);
|
|
||||||
Assert.Equal(8, second.HoleCount);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(tempFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using OpenNest.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Shapes;
|
||||||
|
|
||||||
|
public class NgonShapeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_Octagon_BoundingBoxFitsWithinExpectedSize()
|
||||||
|
{
|
||||||
|
var shape = new NgonShape { Sides = 8, Width = 20 };
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
var bbox = drawing.Program.BoundingBox();
|
||||||
|
// Corner-to-corner is larger than flat-to-flat
|
||||||
|
Assert.True(bbox.Width >= 20 - 0.01);
|
||||||
|
Assert.True(bbox.Length >= 20 - 0.01);
|
||||||
|
// But should not be wildly larger (corner-to-corner ~ width / cos(22.5deg) ~ width * 1.0824)
|
||||||
|
Assert.True(bbox.Width < 22);
|
||||||
|
Assert.True(bbox.Length < 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(4)]
|
||||||
|
[InlineData(5)]
|
||||||
|
[InlineData(6)]
|
||||||
|
[InlineData(8)]
|
||||||
|
[InlineData(12)]
|
||||||
|
public void GetDrawing_HasOneLinearMovePerSide(int sides)
|
||||||
|
{
|
||||||
|
var shape = new NgonShape { Sides = sides, Width = 20 };
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
var moves = drawing.Program.Codes
|
||||||
|
.OfType<OpenNest.CNC.LinearMove>()
|
||||||
|
.Count();
|
||||||
|
Assert.Equal(sides, moves);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_ClampsSidesBelowThreeToTriangle()
|
||||||
|
{
|
||||||
|
var shape = new NgonShape { Sides = 2, Width = 20 };
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
var moves = drawing.Program.Codes
|
||||||
|
.OfType<OpenNest.CNC.LinearMove>()
|
||||||
|
.Count();
|
||||||
|
Assert.Equal(3, moves);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using OpenNest.Shapes;
|
|
||||||
|
|
||||||
namespace OpenNest.Tests.Shapes;
|
|
||||||
|
|
||||||
public class OctagonShapeTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void GetDrawing_BoundingBoxFitsWithinExpectedSize()
|
|
||||||
{
|
|
||||||
var shape = new OctagonShape { Width = 20 };
|
|
||||||
var drawing = shape.GetDrawing();
|
|
||||||
|
|
||||||
var bbox = drawing.Program.BoundingBox();
|
|
||||||
// Corner-to-corner is larger than flat-to-flat
|
|
||||||
Assert.True(bbox.Width >= 20 - 0.01);
|
|
||||||
Assert.True(bbox.Length >= 20 - 0.01);
|
|
||||||
// But should not be wildly larger (corner-to-corner ~ width / cos(22.5deg) ~ width * 1.0824)
|
|
||||||
Assert.True(bbox.Width < 22);
|
|
||||||
Assert.True(bbox.Length < 22);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetDrawing_HasEightEdges()
|
|
||||||
{
|
|
||||||
var shape = new OctagonShape { Width = 20 };
|
|
||||||
var drawing = shape.GetDrawing();
|
|
||||||
|
|
||||||
// An octagon program should have 8 linear moves (one per edge)
|
|
||||||
var moves = drawing.Program.Codes
|
|
||||||
.OfType<OpenNest.CNC.LinearMove>()
|
|
||||||
.Count();
|
|
||||||
Assert.Equal(8, moves);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using OpenNest.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Shapes;
|
||||||
|
|
||||||
|
public class PipeFlangeShapeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_BoundingBoxMatchesOD()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
var bbox = drawing.Program.BoundingBox();
|
||||||
|
Assert.Equal(10, bbox.Width, 0.01);
|
||||||
|
Assert.Equal(10, bbox.Length, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_AreaExcludesBoltHoles()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4,
|
||||||
|
Blind = true
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
var expectedArea = System.Math.PI * 24;
|
||||||
|
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_DefaultName_IsPipeFlange()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
Assert.Equal("PipeFlange", drawing.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_WithPipeSize_CutsCenterBoreAtPipeODPlusClearance()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4,
|
||||||
|
PipeSize = "2", // OD = 2.375
|
||||||
|
PipeClearance = 0.125,
|
||||||
|
Blind = false
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
// Expected bore diameter = 2.375 + 0.125 = 2.5
|
||||||
|
// Area = pi * (5^2 - 0.5^2 * 4 - 1.25^2) = pi * (25 - 1 - 1.5625) = pi * 22.4375
|
||||||
|
var expectedArea = System.Math.PI * 22.4375;
|
||||||
|
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_Blind_OmitsCenterBore()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4,
|
||||||
|
PipeSize = "2",
|
||||||
|
PipeClearance = 0.125,
|
||||||
|
Blind = true
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
// With Blind=true, area = outer - 4 bolt holes = pi * (25 - 1) = pi * 24
|
||||||
|
var expectedArea = System.Math.PI * 24;
|
||||||
|
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_UnknownPipeSize_OmitsCenterBore()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4,
|
||||||
|
PipeSize = "not-a-real-pipe",
|
||||||
|
PipeClearance = 0.125,
|
||||||
|
Blind = false
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
// Unknown pipe size → no bore, area matches blind case
|
||||||
|
var expectedArea = System.Math.PI * 24;
|
||||||
|
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public void GetDrawing_NullOrEmptyPipeSize_OmitsCenterBore(string pipeSize)
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4,
|
||||||
|
PipeSize = pipeSize,
|
||||||
|
PipeClearance = 0.125
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
var expectedArea = System.Math.PI * 24;
|
||||||
|
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_ProducesCorrectDrawing()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Name": "2in-150#",
|
||||||
|
"PipeSize": "2",
|
||||||
|
"PipeClearance": 0.0625,
|
||||||
|
"OD": 6.0,
|
||||||
|
"HoleDiameter": 0.75,
|
||||||
|
"HolePatternDiameter": 4.75,
|
||||||
|
"HoleCount": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "2in-300#",
|
||||||
|
"PipeSize": "2",
|
||||||
|
"PipeClearance": 0.0625,
|
||||||
|
"OD": 6.5,
|
||||||
|
"HoleDiameter": 0.75,
|
||||||
|
"HolePatternDiameter": 5.0,
|
||||||
|
"HoleCount": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
|
||||||
|
var tempFile = Path.GetTempFileName();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(tempFile, json);
|
||||||
|
|
||||||
|
var flanges = ShapeDefinition.LoadFromJson<PipeFlangeShape>(tempFile);
|
||||||
|
|
||||||
|
Assert.Equal(2, flanges.Count);
|
||||||
|
|
||||||
|
var first = flanges[0];
|
||||||
|
Assert.Equal("2in-150#", first.Name);
|
||||||
|
Assert.Equal("2", first.PipeSize);
|
||||||
|
Assert.Equal(0.0625, first.PipeClearance, 0.0001);
|
||||||
|
var drawing = first.GetDrawing();
|
||||||
|
var bbox = drawing.Program.BoundingBox();
|
||||||
|
Assert.Equal(6, bbox.Width, 0.01);
|
||||||
|
|
||||||
|
var second = flanges[1];
|
||||||
|
Assert.Equal("2in-300#", second.Name);
|
||||||
|
Assert.Equal(8, second.HoleCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_RealShippedConfig_LoadsAllEntries()
|
||||||
|
{
|
||||||
|
// Resolve the repo-relative config path from the test binary location.
|
||||||
|
var dir = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
|
while (dir != null && !File.Exists(Path.Combine(dir, "OpenNest.sln")))
|
||||||
|
dir = Path.GetDirectoryName(dir);
|
||||||
|
|
||||||
|
Assert.NotNull(dir);
|
||||||
|
|
||||||
|
var configPath = Path.Combine(dir, "OpenNest", "Configurations", "PipeFlangeShape.json");
|
||||||
|
Assert.True(File.Exists(configPath), $"Config missing at {configPath}");
|
||||||
|
|
||||||
|
var flanges = ShapeDefinition.LoadFromJson<PipeFlangeShape>(configPath);
|
||||||
|
|
||||||
|
Assert.NotEmpty(flanges);
|
||||||
|
foreach (var f in flanges)
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(f.PipeSize));
|
||||||
|
Assert.True(PipeSizes.TryGetOD(f.PipeSize, out _),
|
||||||
|
$"Unknown PipeSize '{f.PipeSize}' in entry '{f.Name}'");
|
||||||
|
Assert.Equal(0.0625, f.PipeClearance, 0.0001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using OpenNest.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Shapes;
|
||||||
|
|
||||||
|
public class PipeSizesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void All_ContainsExpectedCount()
|
||||||
|
{
|
||||||
|
Assert.Equal(35, PipeSizes.All.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_IsSortedByOuterDiameterAscending()
|
||||||
|
{
|
||||||
|
for (var i = 1; i < PipeSizes.All.Count; i++)
|
||||||
|
Assert.True(PipeSizes.All[i].OuterDiameter > PipeSizes.All[i - 1].OuterDiameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1/8", 0.405)]
|
||||||
|
[InlineData("1/2", 0.840)]
|
||||||
|
[InlineData("2", 2.375)]
|
||||||
|
[InlineData("2 1/2", 2.875)]
|
||||||
|
[InlineData("12", 12.750)]
|
||||||
|
[InlineData("48", 48.000)]
|
||||||
|
public void TryGetOD_KnownLabel_ReturnsExpectedOD(string label, double expected)
|
||||||
|
{
|
||||||
|
Assert.True(PipeSizes.TryGetOD(label, out var od));
|
||||||
|
Assert.Equal(expected, od, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetOD_UnknownLabel_ReturnsFalse()
|
||||||
|
{
|
||||||
|
Assert.False(PipeSizes.TryGetOD("bogus", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetFittingSizes_FiltersByMaxOD()
|
||||||
|
{
|
||||||
|
var results = PipeSizes.GetFittingSizes(3.0).ToList();
|
||||||
|
|
||||||
|
Assert.Contains(results, e => e.Label == "2 1/2");
|
||||||
|
Assert.DoesNotContain(results, e => e.Label == "3");
|
||||||
|
Assert.DoesNotContain(results, e => e.Label == "4");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetFittingSizes_ExactBoundary_IsInclusive()
|
||||||
|
{
|
||||||
|
// NPS 3 has OD 3.500; passing maxOD = 3.500 should include it.
|
||||||
|
var results = PipeSizes.GetFittingSizes(3.500).ToList();
|
||||||
|
|
||||||
|
Assert.Contains(results, e => e.Label == "3");
|
||||||
|
Assert.DoesNotContain(results, e => e.Label == "3 1/2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetFittingSizes_MaxSmallerThanSmallest_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Empty(PipeSizes.GetFittingSizes(0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Shapes;
|
||||||
|
|
||||||
|
public class PlateSizesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void All_IsNotEmpty()
|
||||||
|
{
|
||||||
|
Assert.NotEmpty(PlateSizes.All);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_DoesNotContain48x48()
|
||||||
|
{
|
||||||
|
// 48x48 is not a standard sheet - it's the default MinSheet threshold only.
|
||||||
|
Assert.DoesNotContain(PlateSizes.All, e => e.Width == 48 && e.Length == 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_Smallest_Is48x96()
|
||||||
|
{
|
||||||
|
var smallest = PlateSizes.All.OrderBy(e => e.Area).First();
|
||||||
|
Assert.Equal(48, smallest.Width);
|
||||||
|
Assert.Equal(96, smallest.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_SortedByAreaAscending()
|
||||||
|
{
|
||||||
|
for (var i = 1; i < PlateSizes.All.Count; i++)
|
||||||
|
Assert.True(PlateSizes.All[i].Area >= PlateSizes.All[i - 1].Area);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_Entries_AreCanonical_WidthLessOrEqualLength()
|
||||||
|
{
|
||||||
|
foreach (var entry in PlateSizes.All)
|
||||||
|
Assert.True(entry.Width <= entry.Length, $"{entry.Label} not in canonical orientation");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(40, 40, true)] // small - fits trivially
|
||||||
|
[InlineData(48, 96, true)] // exact
|
||||||
|
[InlineData(96, 48, true)] // rotated exact
|
||||||
|
[InlineData(90, 40, true)] // rotated
|
||||||
|
[InlineData(49, 97, false)] // just over in both dims
|
||||||
|
[InlineData(50, 50, false)] // too wide in both orientations
|
||||||
|
public void Entry_Fits_RespectsRotation(double w, double h, bool expected)
|
||||||
|
{
|
||||||
|
var entry = new PlateSizes.Entry("48x96", 48, 96);
|
||||||
|
Assert.Equal(expected, entry.Fits(w, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGet_KnownLabel_ReturnsEntry()
|
||||||
|
{
|
||||||
|
Assert.True(PlateSizes.TryGet("48x96", out var entry));
|
||||||
|
Assert.Equal(48, entry.Width);
|
||||||
|
Assert.Equal(96, entry.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGet_IsCaseInsensitive()
|
||||||
|
{
|
||||||
|
Assert.True(PlateSizes.TryGet("48X96", out var entry));
|
||||||
|
Assert.Equal(48, entry.Width);
|
||||||
|
Assert.Equal(96, entry.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGet_UnknownLabel_ReturnsFalse()
|
||||||
|
{
|
||||||
|
Assert.False(PlateSizes.TryGet("bogus", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_BelowMin_SnapsToDefaultIncrementOfOne()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 10.3, 20.7);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal(11, result.Width);
|
||||||
|
Assert.Equal(21, result.Length);
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_BelowMin_UsesCustomIncrement()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 10.3, 20.7);
|
||||||
|
var options = new PlateSizeOptions { SnapIncrement = 0.25 };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal(10.5, result.Width, 4);
|
||||||
|
Assert.Equal(20.75, result.Length, 4);
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_ExactlyAtMin_Snaps()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 48, 48);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal(48, result.Width);
|
||||||
|
Assert.Equal(48, result.Length);
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_AboveMin_PicksSmallestContainingStandardSheet()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 40, 90);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal(48, result.Width);
|
||||||
|
Assert.Equal(96, result.Length);
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_AboveMin_WithRotation_PicksSmallestSheet()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 90, 40);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_JustOver48_PicksNextStandardSize()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 50, 100);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal(60, result.Width);
|
||||||
|
Assert.Equal(120, result.Length);
|
||||||
|
Assert.Equal("60x120", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_MarginIsAppliedPerSide()
|
||||||
|
{
|
||||||
|
// 46 + 2*1 = 48 (fits exactly), 94 + 2*1 = 96 (fits exactly)
|
||||||
|
var bbox = new Box(0, 0, 46, 94);
|
||||||
|
var options = new PlateSizeOptions { Margin = 1 };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_MarginPushesToNextSheet()
|
||||||
|
{
|
||||||
|
// 47 + 2 = 49 > 48, so 48x96 no longer fits -> next standard
|
||||||
|
var bbox = new Box(0, 0, 47, 95);
|
||||||
|
var options = new PlateSizeOptions { Margin = 1 };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.NotEqual("48x96", result.MatchedLabel);
|
||||||
|
Assert.True(result.Width >= 49);
|
||||||
|
Assert.True(result.Length >= 97);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_AllowedSizes_StandardLabelWhitelist()
|
||||||
|
{
|
||||||
|
// 60x120 is the only option; 50x50 is above min so it routes to standard
|
||||||
|
var bbox = new Box(0, 0, 50, 50);
|
||||||
|
var options = new PlateSizeOptions { AllowedSizes = new[] { "60x120" } };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal("60x120", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_AllowedSizes_ArbitraryWxHString()
|
||||||
|
{
|
||||||
|
// 50x100 isn't in the standard catalog but is valid as an ad-hoc entry.
|
||||||
|
// bbox 49x99 doesn't fit 48x96 or 48x120, does fit 50x100 and 60x120,
|
||||||
|
// but only 50x100 is allowed.
|
||||||
|
var bbox = new Box(0, 0, 49, 99);
|
||||||
|
var options = new PlateSizeOptions { AllowedSizes = new[] { "50x100" } };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal(50, result.Width);
|
||||||
|
Assert.Equal(100, result.Length);
|
||||||
|
Assert.Equal("50x100", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_NothingFits_FallsBackToSnapUp()
|
||||||
|
{
|
||||||
|
// Larger than any catalog sheet
|
||||||
|
var bbox = new Box(0, 0, 100, 300);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal(100, result.Width);
|
||||||
|
Assert.Equal(300, result.Length);
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_NothingFitsInAllowedList_FallsBackToSnapUp()
|
||||||
|
{
|
||||||
|
// Only 48x96 allowed, but bbox is too big for it
|
||||||
|
var bbox = new Box(0, 0, 50, 100);
|
||||||
|
var options = new PlateSizeOptions { AllowedSizes = new[] { "48x96" } };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal(50, result.Width);
|
||||||
|
Assert.Equal(100, result.Length);
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_BoxEnumerable_CombinesIntoEnvelope()
|
||||||
|
{
|
||||||
|
// Two boxes that together span 0..40 x 0..90 -> fits 48x96
|
||||||
|
var boxes = new[]
|
||||||
|
{
|
||||||
|
new Box(0, 0, 40, 50),
|
||||||
|
new Box(0, 40, 30, 50),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(boxes);
|
||||||
|
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_BoxEnumerable_Empty_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<System.ArgumentException>(
|
||||||
|
() => PlateSizes.Recommend(System.Array.Empty<Box>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlateSizeOptions_Defaults()
|
||||||
|
{
|
||||||
|
var options = new PlateSizeOptions();
|
||||||
|
|
||||||
|
Assert.Equal(48, options.MinSheetWidth);
|
||||||
|
Assert.Equal(48, options.MinSheetLength);
|
||||||
|
Assert.Equal(1.0, options.SnapIncrement);
|
||||||
|
Assert.Equal(0, options.Margin);
|
||||||
|
Assert.Null(options.AllowedSizes);
|
||||||
|
Assert.Equal(PlateSizeSelection.SmallestArea, options.Selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_NarrowestFirst_PicksNarrowerSheetOverSmallerArea()
|
||||||
|
{
|
||||||
|
// Hypothetical: bbox (47, 47) fits both 48x96 (area 4608) and some narrower option.
|
||||||
|
// With SmallestArea: picks 48x96 (it's already the smallest 48-wide).
|
||||||
|
// With NarrowestFirst: also picks 48x96 since that's the narrowest.
|
||||||
|
// Better test: AllowedSizes = ["60x120", "48x120"] with bbox that fits both.
|
||||||
|
// 48x120 (area 5760) is narrower; 60x120 (area 7200) has more area.
|
||||||
|
// SmallestArea picks 48x120; NarrowestFirst also picks 48x120. Both pick the same.
|
||||||
|
//
|
||||||
|
// Real divergence: AllowedSizes = ["60x120", "72x120"] with bbox 55x100.
|
||||||
|
// 60x120 has narrower width (60) AND smaller area (7200 vs 8640), so both agree.
|
||||||
|
//
|
||||||
|
// To force divergence: AllowedSizes = ["60x96", "48x144"] with bbox 47x95.
|
||||||
|
// 60x96 area = 5760, 48x144 area = 6912. SmallestArea -> 60x96.
|
||||||
|
// NarrowestFirst width 48 < 60 -> 48x144.
|
||||||
|
var bbox = new Box(0, 0, 47, 95);
|
||||||
|
var options = new PlateSizeOptions
|
||||||
|
{
|
||||||
|
AllowedSizes = new[] { "60x96", "48x144" },
|
||||||
|
Selection = PlateSizeSelection.NarrowestFirst,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal(48, result.Width);
|
||||||
|
Assert.Equal(144, result.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_SmallestArea_PicksSmallerAreaOverNarrowerWidth()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 47, 95);
|
||||||
|
var options = new PlateSizeOptions
|
||||||
|
{
|
||||||
|
AllowedSizes = new[] { "60x96", "48x144" },
|
||||||
|
Selection = PlateSizeSelection.SmallestArea,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal(60, result.Width);
|
||||||
|
Assert.Equal(96, result.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -384,6 +384,161 @@ public class DrawingSplitterTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips()
|
||||||
|
{
|
||||||
|
// 255x55 outer rectangle with a 235x35 interior slot centered at (10,10)-(245,45).
|
||||||
|
// 4 vertical splits at x = 55, 110, 165, 220.
|
||||||
|
//
|
||||||
|
// Expected: regions R2/R3/R4 are entirely "over" the slot horizontally, so the
|
||||||
|
// surviving material in each is two physically disjoint strips (upper + lower).
|
||||||
|
// R1 and R5 each have a solid edge that connects the top and bottom strips, so
|
||||||
|
// they remain single (notched) pieces.
|
||||||
|
//
|
||||||
|
// Total output drawings: 1 (R1) + 2 (R2) + 2 (R3) + 2 (R4) + 1 (R5) = 8.
|
||||||
|
var outerEntities = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(new Vector(0, 0), new Vector(255, 0)),
|
||||||
|
new Line(new Vector(255, 0), new Vector(255, 55)),
|
||||||
|
new Line(new Vector(255, 55), new Vector(0, 55)),
|
||||||
|
new Line(new Vector(0, 55), new Vector(0, 0))
|
||||||
|
};
|
||||||
|
var slotEntities = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(new Vector(10, 10), new Vector(245, 10)),
|
||||||
|
new Line(new Vector(245, 10), new Vector(245, 45)),
|
||||||
|
new Line(new Vector(245, 45), new Vector(10, 45)),
|
||||||
|
new Line(new Vector(10, 45), new Vector(10, 10))
|
||||||
|
};
|
||||||
|
var allEntities = new List<Entity>();
|
||||||
|
allEntities.AddRange(outerEntities);
|
||||||
|
allEntities.AddRange(slotEntities);
|
||||||
|
|
||||||
|
var drawing = new Drawing("SLOT", ConvertGeometry.ToProgram(allEntities));
|
||||||
|
var originalArea = drawing.Area;
|
||||||
|
|
||||||
|
var splitLines = new List<SplitLine>
|
||||||
|
{
|
||||||
|
new SplitLine(55.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(110.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(165.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(220.0, CutOffAxis.Vertical)
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight });
|
||||||
|
|
||||||
|
// R1 (0..55) → 1 notched piece, height 55
|
||||||
|
// R2 (55..110) → upper strip + lower strip, each height 10
|
||||||
|
// R3 (110..165)→ upper strip + lower strip, each height 10
|
||||||
|
// R4 (165..220)→ upper strip + lower strip, each height 10
|
||||||
|
// R5 (220..255)→ 1 notched piece, height 55
|
||||||
|
Assert.Equal(8, results.Count);
|
||||||
|
|
||||||
|
// Area preservation: sum of all output areas equals (outer − slot).
|
||||||
|
var totalArea = results.Sum(d => d.Area);
|
||||||
|
Assert.Equal(originalArea, totalArea, 1);
|
||||||
|
|
||||||
|
// Box.Length = X-extent, Box.Width = Y-extent.
|
||||||
|
// Exactly 6 strips (Y-extent ~10mm) from the three middle regions, and
|
||||||
|
// exactly 2 notched pieces (Y-extent 55mm) from R1 and R5.
|
||||||
|
var strips = results
|
||||||
|
.Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 10.0) < 0.5)
|
||||||
|
.ToList();
|
||||||
|
var notched = results
|
||||||
|
.Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 55.0) < 0.5)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Equal(6, strips.Count);
|
||||||
|
Assert.Equal(2, notched.Count);
|
||||||
|
|
||||||
|
// Each piece should form a closed perimeter (no dangling edges, no gaps).
|
||||||
|
foreach (var piece in results)
|
||||||
|
{
|
||||||
|
var entities = ConvertProgram.ToGeometry(piece.Program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||||
|
|
||||||
|
Assert.True(entities.Count >= 3, $"{piece.Name} must have at least 3 edges");
|
||||||
|
|
||||||
|
for (var i = 0; i < entities.Count; i++)
|
||||||
|
{
|
||||||
|
var end = GetEndPoint(entities[i]);
|
||||||
|
var nextStart = GetStartPoint(entities[(i + 1) % entities.Count]);
|
||||||
|
var gap = end.DistanceTo(nextStart);
|
||||||
|
Assert.True(gap < 0.01,
|
||||||
|
$"{piece.Name} gap of {gap:F4} between edge {i} end and edge {(i + 1) % entities.Count} start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Split_DxfFile_WithSpanningSlot_HasNoCutLinesThroughCutout()
|
||||||
|
{
|
||||||
|
// Real DXF regression: 255x55 plate with a centered slot cutout, split into
|
||||||
|
// five columns. Exercises the same path as the synthetic
|
||||||
|
// Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips test but through
|
||||||
|
// the full DXF import pipeline.
|
||||||
|
var path = Path.Combine(AppContext.BaseDirectory, "Splitting", "TestData", "split_test.dxf");
|
||||||
|
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
|
||||||
|
|
||||||
|
var imported = OpenNest.IO.Dxf.Import(path);
|
||||||
|
var profile = new OpenNest.Geometry.ShapeProfile(imported.Entities);
|
||||||
|
|
||||||
|
// Normalize to origin so the split line positions are predictable.
|
||||||
|
var bb = profile.Perimeter.BoundingBox;
|
||||||
|
var offsetX = -bb.X;
|
||||||
|
var offsetY = -bb.Y;
|
||||||
|
foreach (var e in profile.Perimeter.Entities) e.Offset(offsetX, offsetY);
|
||||||
|
foreach (var cutout in profile.Cutouts)
|
||||||
|
foreach (var e in cutout.Entities) e.Offset(offsetX, offsetY);
|
||||||
|
|
||||||
|
var allEntities = new List<Entity>();
|
||||||
|
allEntities.AddRange(profile.Perimeter.Entities);
|
||||||
|
foreach (var cutout in profile.Cutouts) allEntities.AddRange(cutout.Entities);
|
||||||
|
|
||||||
|
var drawing = new Drawing("SPLITTEST", ConvertGeometry.ToProgram(allEntities));
|
||||||
|
var originalArea = drawing.Area;
|
||||||
|
|
||||||
|
// Part is ~255x55 with an interior slot. Split into 5 columns (55mm each).
|
||||||
|
var splitLines = new List<SplitLine>
|
||||||
|
{
|
||||||
|
new SplitLine(55.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(110.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(165.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(220.0, CutOffAxis.Vertical)
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight });
|
||||||
|
|
||||||
|
// Area must be preserved within tolerance (floating-point coords in the DXF).
|
||||||
|
var totalArea = results.Sum(d => d.Area);
|
||||||
|
Assert.Equal(originalArea, totalArea, 0);
|
||||||
|
|
||||||
|
// At least one region must yield more than one physical strip — that's the
|
||||||
|
// whole point of the fix: a cutout that spans a region disconnects it.
|
||||||
|
Assert.True(results.Count > splitLines.Count + 1,
|
||||||
|
$"Expected more than {splitLines.Count + 1} pieces (some regions split into strips), got {results.Count}");
|
||||||
|
|
||||||
|
// Every output drawing must resolve into fully-closed shapes (outer loop
|
||||||
|
// and any hole loops), with no dangling geometry. A piece that contains
|
||||||
|
// a cutout will have its entities span more than one connected loop.
|
||||||
|
foreach (var piece in results)
|
||||||
|
{
|
||||||
|
var entities = ConvertProgram.ToGeometry(piece.Program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||||
|
|
||||||
|
Assert.True(entities.Count >= 3, $"{piece.Name} has only {entities.Count} entities");
|
||||||
|
|
||||||
|
var shapes = OpenNest.Geometry.ShapeBuilder.GetShapes(entities);
|
||||||
|
Assert.NotEmpty(shapes);
|
||||||
|
|
||||||
|
foreach (var shape in shapes)
|
||||||
|
{
|
||||||
|
Assert.True(shape.IsClosed(),
|
||||||
|
$"{piece.Name} contains an open chain of {shape.Entities.Count} entities");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Vector GetStartPoint(Entity entity)
|
private static Vector GetStartPoint(Entity entity)
|
||||||
{
|
{
|
||||||
return entity switch
|
return entity switch
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
using OpenNest.IO.Bom;
|
using OpenNest.Math;
|
||||||
using System;
|
using System;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ namespace OpenNest
|
|||||||
private Color edgeSpacingColor;
|
private Color edgeSpacingColor;
|
||||||
private Color previewPartColor;
|
private Color previewPartColor;
|
||||||
|
|
||||||
public static Color[] PartColors => Drawing.PartColors;
|
public string Name { get; set; } = "Unnamed";
|
||||||
|
|
||||||
|
public Color[] PartColors { get; set; } = Drawing.PartColors;
|
||||||
|
|
||||||
public static readonly ColorScheme Default = new ColorScheme
|
public static readonly ColorScheme Default = new ColorScheme
|
||||||
{
|
{
|
||||||
|
Name = "Classic",
|
||||||
BackgroundColor = Color.DarkGray,
|
BackgroundColor = Color.DarkGray,
|
||||||
LayoutOutlineColor = Color.Gray,
|
LayoutOutlineColor = Color.Gray,
|
||||||
LayoutFillColor = Color.WhiteSmoke,
|
LayoutFillColor = Color.WhiteSmoke,
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
using OpenNest.Forms;
|
||||||
|
using OpenNest.Properties;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public static class ColorSchemeRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, ColorScheme> builtIns =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Classic"] = BuildClassic(),
|
||||||
|
["Pastel"] = BuildPastel(),
|
||||||
|
["Dark"] = BuildDark()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static List<ColorScheme> diskCache;
|
||||||
|
|
||||||
|
public static IEnumerable<ColorScheme> AllSchemes
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
diskCache ??= LoadDiskSchemes().ToList();
|
||||||
|
return builtIns.Values.Concat(diskCache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Refresh() => diskCache = null;
|
||||||
|
|
||||||
|
public static ColorScheme Get(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
return builtIns["Classic"];
|
||||||
|
|
||||||
|
var hit = AllSchemes.FirstOrDefault(
|
||||||
|
s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return hit ?? builtIns["Classic"];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ApplyActiveFromSettings()
|
||||||
|
{
|
||||||
|
var name = Settings.Default.ActiveColorScheme;
|
||||||
|
var scheme = Get(name);
|
||||||
|
Apply(scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Apply(ColorScheme scheme)
|
||||||
|
{
|
||||||
|
var d = ColorScheme.Default;
|
||||||
|
d.Name = scheme.Name;
|
||||||
|
d.BackgroundColor = scheme.BackgroundColor;
|
||||||
|
d.LayoutOutlineColor = scheme.LayoutOutlineColor;
|
||||||
|
d.LayoutFillColor = scheme.LayoutFillColor;
|
||||||
|
d.BoundingBoxColor = scheme.BoundingBoxColor;
|
||||||
|
d.RapidColor = scheme.RapidColor;
|
||||||
|
d.OriginColor = scheme.OriginColor;
|
||||||
|
d.EdgeSpacingColor = scheme.EdgeSpacingColor;
|
||||||
|
d.PreviewPartColor = scheme.PreviewPartColor;
|
||||||
|
d.PartColors = scheme.PartColors;
|
||||||
|
|
||||||
|
Drawing.PartColors = scheme.PartColors;
|
||||||
|
|
||||||
|
RecolorOpenNests(scheme.PartColors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecolorOpenNests(Color[] palette)
|
||||||
|
{
|
||||||
|
foreach (Form f in Application.OpenForms)
|
||||||
|
{
|
||||||
|
if (f is not EditNestForm enf)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
foreach (var drawing in enf.Nest.Drawings)
|
||||||
|
{
|
||||||
|
if (drawing.IsCutOff)
|
||||||
|
continue;
|
||||||
|
drawing.Color = palette[i % palette.Length];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<ColorScheme> LoadDiskSchemes()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(AppContext.BaseDirectory, "Schemes");
|
||||||
|
if (!Directory.Exists(dir))
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
foreach (var path in Directory.GetFiles(dir, "*.json"))
|
||||||
|
{
|
||||||
|
ColorScheme scheme;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scheme = ColorSchemeSerializer.Deserialize(File.ReadAllText(path));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!builtIns.ContainsKey(scheme.Name))
|
||||||
|
yield return scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ColorScheme BuildClassic() => new ColorScheme
|
||||||
|
{
|
||||||
|
Name = "Classic",
|
||||||
|
BackgroundColor = Color.DarkGray,
|
||||||
|
LayoutOutlineColor = Color.Gray,
|
||||||
|
LayoutFillColor = Color.WhiteSmoke,
|
||||||
|
BoundingBoxColor = Color.FromArgb(128, 128, 255),
|
||||||
|
RapidColor = Color.DodgerBlue,
|
||||||
|
OriginColor = Color.Gray,
|
||||||
|
EdgeSpacingColor = Color.FromArgb(180, 180, 180),
|
||||||
|
PreviewPartColor = Color.FromArgb(255, 140, 0),
|
||||||
|
PartColors = new[]
|
||||||
|
{
|
||||||
|
Color.FromArgb(205, 92, 92),
|
||||||
|
Color.FromArgb(148, 103, 189),
|
||||||
|
Color.FromArgb(75, 180, 175),
|
||||||
|
Color.FromArgb(210, 190, 75),
|
||||||
|
Color.FromArgb(190, 85, 175),
|
||||||
|
Color.FromArgb(185, 115, 85),
|
||||||
|
Color.FromArgb(120, 100, 190),
|
||||||
|
Color.FromArgb(200, 100, 140),
|
||||||
|
Color.FromArgb(80, 175, 155),
|
||||||
|
Color.FromArgb(195, 160, 85),
|
||||||
|
Color.FromArgb(175, 95, 160),
|
||||||
|
Color.FromArgb(215, 130, 130),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ColorScheme BuildPastel() => new ColorScheme
|
||||||
|
{
|
||||||
|
Name = "Pastel",
|
||||||
|
BackgroundColor = Color.FromArgb(70, 75, 85),
|
||||||
|
LayoutOutlineColor = Color.FromArgb(180, 180, 190),
|
||||||
|
LayoutFillColor = Color.FromArgb(245, 245, 248),
|
||||||
|
BoundingBoxColor = Color.FromArgb(128, 128, 255),
|
||||||
|
RapidColor = Color.DodgerBlue,
|
||||||
|
OriginColor = Color.FromArgb(160, 160, 160),
|
||||||
|
EdgeSpacingColor = Color.FromArgb(200, 200, 210),
|
||||||
|
PreviewPartColor = Color.FromArgb(255, 140, 0),
|
||||||
|
PartColors = new[]
|
||||||
|
{
|
||||||
|
Color.FromArgb(122, 179, 209), Color.FromArgb(254, 229, 174),
|
||||||
|
Color.FromArgb(143, 177, 229), Color.FromArgb(167, 172, 227),
|
||||||
|
Color.FromArgb(216, 249, 195), Color.FromArgb(209, 168, 216),
|
||||||
|
Color.FromArgb(222, 157, 190), Color.FromArgb(176, 255, 240),
|
||||||
|
Color.FromArgb(235, 205, 153), Color.FromArgb(177, 225, 180),
|
||||||
|
Color.FromArgb(125, 202, 241), Color.FromArgb(187, 206, 151),
|
||||||
|
Color.FromArgb(251, 175, 190), Color.FromArgb(129, 226, 227),
|
||||||
|
Color.FromArgb(255, 253, 207), Color.FromArgb(235, 205, 255),
|
||||||
|
Color.FromArgb(255, 197, 168), Color.FromArgb(116, 213, 234),
|
||||||
|
Color.FromArgb(190, 169, 122), Color.FromArgb(213, 159, 135),
|
||||||
|
Color.FromArgb(124, 184, 155), Color.FromArgb(255, 189, 214),
|
||||||
|
Color.FromArgb(146, 222, 255), Color.FromArgb(177, 173, 125),
|
||||||
|
Color.FromArgb(177, 166, 202), Color.FromArgb(197, 208, 255),
|
||||||
|
Color.FromArgb(255, 209, 243), Color.FromArgb(210, 255, 237),
|
||||||
|
Color.FromArgb(255, 237, 204), Color.FromArgb(167, 233, 255),
|
||||||
|
Color.FromArgb(182, 220, 255), Color.FromArgb(159, 177, 142),
|
||||||
|
Color.FromArgb(190, 248, 255), Color.FromArgb(187, 169, 136),
|
||||||
|
Color.FromArgb(199, 162, 168), Color.FromArgb(250, 255, 239),
|
||||||
|
Color.FromArgb(222, 233, 255), Color.FromArgb(255, 234, 225),
|
||||||
|
Color.FromArgb(240, 249, 255), Color.FromArgb(152, 176, 176),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ColorScheme BuildDark() => new ColorScheme
|
||||||
|
{
|
||||||
|
Name = "Dark",
|
||||||
|
BackgroundColor = Color.FromArgb(30, 30, 34),
|
||||||
|
LayoutOutlineColor = Color.FromArgb(90, 90, 95),
|
||||||
|
LayoutFillColor = Color.FromArgb(50, 50, 55),
|
||||||
|
BoundingBoxColor = Color.FromArgb(100, 160, 220),
|
||||||
|
RapidColor = Color.FromArgb(255, 200, 50),
|
||||||
|
OriginColor = Color.FromArgb(120, 120, 130),
|
||||||
|
EdgeSpacingColor = Color.FromArgb(90, 90, 100),
|
||||||
|
PreviewPartColor = Color.FromArgb(255, 170, 60),
|
||||||
|
PartColors = new[]
|
||||||
|
{
|
||||||
|
Color.FromArgb(255, 85, 85), // Neon Red
|
||||||
|
Color.FromArgb(80, 220, 255), // Electric Cyan
|
||||||
|
Color.FromArgb(255, 200, 50), // Amber
|
||||||
|
Color.FromArgb(130, 255, 130), // Lime Green
|
||||||
|
Color.FromArgb(255, 130, 220), // Hot Pink
|
||||||
|
Color.FromArgb(255, 165, 70), // Tangerine
|
||||||
|
Color.FromArgb(100, 180, 255), // Sky Blue
|
||||||
|
Color.FromArgb(200, 160, 255), // Lavender
|
||||||
|
Color.FromArgb(50, 230, 180), // Mint
|
||||||
|
Color.FromArgb(255, 255, 100), // Lemon
|
||||||
|
Color.FromArgb(255, 120, 120), // Salmon
|
||||||
|
Color.FromArgb(140, 230, 255), // Ice Blue
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public static class ColorSchemeSerializer
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Serialize(ColorScheme scheme)
|
||||||
|
{
|
||||||
|
var dto = new ColorSchemeDto
|
||||||
|
{
|
||||||
|
Name = scheme.Name,
|
||||||
|
BackgroundColor = ToHex(scheme.BackgroundColor),
|
||||||
|
LayoutOutlineColor = ToHex(scheme.LayoutOutlineColor),
|
||||||
|
LayoutFillColor = ToHex(scheme.LayoutFillColor),
|
||||||
|
BoundingBoxColor = ToHex(scheme.BoundingBoxColor),
|
||||||
|
RapidColor = ToHex(scheme.RapidColor),
|
||||||
|
OriginColor = ToHex(scheme.OriginColor),
|
||||||
|
EdgeSpacingColor = ToHex(scheme.EdgeSpacingColor),
|
||||||
|
PreviewPartColor = ToHex(scheme.PreviewPartColor),
|
||||||
|
PartColors = scheme.PartColors.Select(ToHex).ToArray()
|
||||||
|
};
|
||||||
|
return JsonSerializer.Serialize(dto, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ColorScheme Deserialize(string json)
|
||||||
|
{
|
||||||
|
var dto = JsonSerializer.Deserialize<ColorSchemeDto>(json, JsonOptions)
|
||||||
|
?? throw new JsonException("ColorScheme JSON was null");
|
||||||
|
|
||||||
|
return new ColorScheme
|
||||||
|
{
|
||||||
|
Name = dto.Name ?? "Unnamed",
|
||||||
|
BackgroundColor = FromHex(dto.BackgroundColor),
|
||||||
|
LayoutOutlineColor = FromHex(dto.LayoutOutlineColor),
|
||||||
|
LayoutFillColor = FromHex(dto.LayoutFillColor),
|
||||||
|
BoundingBoxColor = FromHex(dto.BoundingBoxColor),
|
||||||
|
RapidColor = FromHex(dto.RapidColor),
|
||||||
|
OriginColor = FromHex(dto.OriginColor),
|
||||||
|
EdgeSpacingColor = FromHex(dto.EdgeSpacingColor),
|
||||||
|
PreviewPartColor = FromHex(dto.PreviewPartColor),
|
||||||
|
PartColors = (dto.PartColors ?? new string[0]).Select(FromHex).ToArray()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToHex(Color c) =>
|
||||||
|
"#" + c.R.ToString("X2") + c.G.ToString("X2") + c.B.ToString("X2");
|
||||||
|
|
||||||
|
private static Color FromHex(string hex)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(hex))
|
||||||
|
return Color.Black;
|
||||||
|
var h = hex.TrimStart('#');
|
||||||
|
if (h.Length < 6)
|
||||||
|
return Color.Black;
|
||||||
|
var r = byte.Parse(h.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||||
|
var g = byte.Parse(h.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||||
|
var b = byte.Parse(h.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||||
|
return Color.FromArgb(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ColorSchemeDto
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string BackgroundColor { get; set; }
|
||||||
|
public string LayoutOutlineColor { get; set; }
|
||||||
|
public string LayoutFillColor { get; set; }
|
||||||
|
public string BoundingBoxColor { get; set; }
|
||||||
|
public string RapidColor { get; set; }
|
||||||
|
public string OriginColor { get; set; }
|
||||||
|
public string EdgeSpacingColor { get; set; }
|
||||||
|
public string PreviewPartColor { get; set; }
|
||||||
|
public string[] PartColors { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+366
-230
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,8 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
private readonly CheckBox chkTabsEnabled;
|
private readonly CheckBox chkTabsEnabled;
|
||||||
private readonly NumericUpDown nudTabWidth;
|
private readonly NumericUpDown nudTabWidth;
|
||||||
|
private readonly RadioButton rbTabAll;
|
||||||
|
private readonly RadioButton rbAutoTab;
|
||||||
private readonly NumericUpDown nudAutoTabMin;
|
private readonly NumericUpDown nudAutoTabMin;
|
||||||
private readonly NumericUpDown nudAutoTabMax;
|
private readonly NumericUpDown nudAutoTabMax;
|
||||||
private readonly NumericUpDown nudPierceClearance;
|
private readonly NumericUpDown nudPierceClearance;
|
||||||
@@ -112,7 +114,7 @@ namespace OpenNest.Controls
|
|||||||
{
|
{
|
||||||
HeaderText = "Tabs",
|
HeaderText = "Tabs",
|
||||||
Dock = DockStyle.Top,
|
Dock = DockStyle.Top,
|
||||||
ExpandedHeight = 120,
|
ExpandedHeight = 160,
|
||||||
IsExpanded = false
|
IsExpanded = false
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,44 +124,78 @@ namespace OpenNest.Controls
|
|||||||
Location = new Point(12, 4),
|
Location = new Point(12, 4),
|
||||||
AutoSize = true
|
AutoSize = true
|
||||||
};
|
};
|
||||||
chkTabsEnabled.CheckedChanged += (s, e) =>
|
|
||||||
{
|
|
||||||
nudTabWidth.Enabled = chkTabsEnabled.Checked;
|
|
||||||
OnParametersChanged();
|
|
||||||
};
|
|
||||||
tabsPanel.ContentPanel.Controls.Add(chkTabsEnabled);
|
tabsPanel.ContentPanel.Controls.Add(chkTabsEnabled);
|
||||||
|
|
||||||
tabsPanel.ContentPanel.Controls.Add(new Label
|
tabsPanel.ContentPanel.Controls.Add(new Label
|
||||||
{
|
{
|
||||||
Text = "Width:",
|
Text = "Tab Size:",
|
||||||
Location = new Point(160, 6),
|
Location = new Point(160, 6),
|
||||||
AutoSize = true
|
AutoSize = true
|
||||||
});
|
});
|
||||||
|
|
||||||
nudTabWidth = CreateNumeric(215, 3, 0.25, 0.0625);
|
nudTabWidth = CreateNumeric(225, 3, 0.25, 0.0625);
|
||||||
nudTabWidth.Enabled = false;
|
nudTabWidth.Enabled = false;
|
||||||
tabsPanel.ContentPanel.Controls.Add(nudTabWidth);
|
tabsPanel.ContentPanel.Controls.Add(nudTabWidth);
|
||||||
|
|
||||||
|
rbTabAll = new RadioButton
|
||||||
|
{
|
||||||
|
Text = "Tab all parts",
|
||||||
|
Location = new Point(28, 28),
|
||||||
|
AutoSize = true,
|
||||||
|
Enabled = false,
|
||||||
|
Checked = true
|
||||||
|
};
|
||||||
|
tabsPanel.ContentPanel.Controls.Add(rbTabAll);
|
||||||
|
|
||||||
|
rbAutoTab = new RadioButton
|
||||||
|
{
|
||||||
|
Text = "Auto-tab when smallest part dimension is between:",
|
||||||
|
Location = new Point(28, 50),
|
||||||
|
AutoSize = true,
|
||||||
|
Enabled = false
|
||||||
|
};
|
||||||
|
tabsPanel.ContentPanel.Controls.Add(rbAutoTab);
|
||||||
|
|
||||||
tabsPanel.ContentPanel.Controls.Add(new Label
|
tabsPanel.ContentPanel.Controls.Add(new Label
|
||||||
{
|
{
|
||||||
Text = "Auto-Tab Min Size:",
|
Text = "Min:",
|
||||||
Location = new Point(12, 32),
|
Location = new Point(44, 76),
|
||||||
AutoSize = true
|
AutoSize = true
|
||||||
});
|
});
|
||||||
|
|
||||||
nudAutoTabMin = CreateNumeric(140, 29, 0, 0.0625);
|
nudAutoTabMin = CreateNumeric(77, 73, 0, 0.0625);
|
||||||
|
nudAutoTabMin.Enabled = false;
|
||||||
tabsPanel.ContentPanel.Controls.Add(nudAutoTabMin);
|
tabsPanel.ContentPanel.Controls.Add(nudAutoTabMin);
|
||||||
|
|
||||||
tabsPanel.ContentPanel.Controls.Add(new Label
|
tabsPanel.ContentPanel.Controls.Add(new Label
|
||||||
{
|
{
|
||||||
Text = "Auto-Tab Max Size:",
|
Text = "Max:",
|
||||||
Location = new Point(12, 58),
|
Location = new Point(210, 76),
|
||||||
AutoSize = true
|
AutoSize = true
|
||||||
});
|
});
|
||||||
|
|
||||||
nudAutoTabMax = CreateNumeric(140, 55, 0, 0.0625);
|
nudAutoTabMax = CreateNumeric(245, 73, 0, 0.0625);
|
||||||
|
nudAutoTabMax.Enabled = false;
|
||||||
tabsPanel.ContentPanel.Controls.Add(nudAutoTabMax);
|
tabsPanel.ContentPanel.Controls.Add(nudAutoTabMax);
|
||||||
|
|
||||||
|
chkTabsEnabled.CheckedChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
var enabled = chkTabsEnabled.Checked;
|
||||||
|
nudTabWidth.Enabled = enabled;
|
||||||
|
rbTabAll.Enabled = enabled;
|
||||||
|
rbAutoTab.Enabled = enabled;
|
||||||
|
nudAutoTabMin.Enabled = enabled && rbAutoTab.Checked;
|
||||||
|
nudAutoTabMax.Enabled = enabled && rbAutoTab.Checked;
|
||||||
|
OnParametersChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
rbTabAll.CheckedChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
nudAutoTabMin.Enabled = chkTabsEnabled.Checked && rbAutoTab.Checked;
|
||||||
|
nudAutoTabMax.Enabled = chkTabsEnabled.Checked && rbAutoTab.Checked;
|
||||||
|
OnParametersChanged();
|
||||||
|
};
|
||||||
|
|
||||||
// Pierce section
|
// Pierce section
|
||||||
var piercePanel = new CollapsiblePanel
|
var piercePanel = new CollapsiblePanel
|
||||||
{
|
{
|
||||||
@@ -246,13 +282,13 @@ namespace OpenNest.Controls
|
|||||||
InternalLeadOut = BuildLeadOut(cboInternalLeadOut, pnlInternalLeadOut),
|
InternalLeadOut = BuildLeadOut(cboInternalLeadOut, pnlInternalLeadOut),
|
||||||
ArcCircleLeadIn = BuildLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn),
|
ArcCircleLeadIn = BuildLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn),
|
||||||
ArcCircleLeadOut = BuildLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut),
|
ArcCircleLeadOut = BuildLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut),
|
||||||
TabsEnabled = chkTabsEnabled.Checked,
|
TabsEnabled = chkTabsEnabled.Checked && rbTabAll.Checked,
|
||||||
TabConfig = new NormalTab { Size = (double)nudTabWidth.Value },
|
TabConfig = new NormalTab { Size = (double)nudTabWidth.Value },
|
||||||
PierceClearance = (double)nudPierceClearance.Value,
|
PierceClearance = (double)nudPierceClearance.Value,
|
||||||
RoundLeadInAngles = chkRoundLeadInAngles.Checked,
|
RoundLeadInAngles = chkRoundLeadInAngles.Checked,
|
||||||
LeadInAngleIncrement = (double)nudLeadInAngleIncrement.Value,
|
LeadInAngleIncrement = (double)nudLeadInAngleIncrement.Value,
|
||||||
AutoTabMinSize = (double)nudAutoTabMin.Value,
|
AutoTabMinSize = chkTabsEnabled.Checked && rbAutoTab.Checked ? (double)nudAutoTabMin.Value : 0,
|
||||||
AutoTabMaxSize = (double)nudAutoTabMax.Value
|
AutoTabMaxSize = chkTabsEnabled.Checked && rbAutoTab.Checked ? (double)nudAutoTabMax.Value : 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +303,10 @@ namespace OpenNest.Controls
|
|||||||
LoadLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn, p.ArcCircleLeadIn);
|
LoadLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn, p.ArcCircleLeadIn);
|
||||||
LoadLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut, p.ArcCircleLeadOut);
|
LoadLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut, p.ArcCircleLeadOut);
|
||||||
|
|
||||||
chkTabsEnabled.Checked = p.TabsEnabled;
|
var hasAutoTab = p.AutoTabMinSize > 0 || p.AutoTabMaxSize > 0;
|
||||||
|
chkTabsEnabled.Checked = p.TabsEnabled || hasAutoTab;
|
||||||
|
rbAutoTab.Checked = hasAutoTab;
|
||||||
|
rbTabAll.Checked = !hasAutoTab;
|
||||||
if (p.TabConfig != null)
|
if (p.TabConfig != null)
|
||||||
nudTabWidth.Value = (decimal)p.TabConfig.Size;
|
nudTabWidth.Value = (decimal)p.TabConfig.Size;
|
||||||
nudPierceClearance.Value = (decimal)p.PierceClearance;
|
nudPierceClearance.Value = (decimal)p.PierceClearance;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ namespace OpenNest.Controls
|
|||||||
public event EventHandler FilterChanged;
|
public event EventHandler FilterChanged;
|
||||||
public event EventHandler<int> BendLineSelected;
|
public event EventHandler<int> BendLineSelected;
|
||||||
public event EventHandler<int> BendLineRemoved;
|
public event EventHandler<int> BendLineRemoved;
|
||||||
|
public event EventHandler<int> BendLineEdited;
|
||||||
public event EventHandler AddBendLineClicked;
|
public event EventHandler AddBendLineClicked;
|
||||||
|
|
||||||
public FilterPanel()
|
public FilterPanel()
|
||||||
@@ -51,6 +52,18 @@ namespace OpenNest.Controls
|
|||||||
bendLinesList.SelectedIndexChanged += (s, e) =>
|
bendLinesList.SelectedIndexChanged += (s, e) =>
|
||||||
BendLineSelected?.Invoke(this, bendLinesList.SelectedIndex);
|
BendLineSelected?.Invoke(this, bendLinesList.SelectedIndex);
|
||||||
|
|
||||||
|
var bendEditLink = new LinkLabel
|
||||||
|
{
|
||||||
|
Text = "Edit",
|
||||||
|
AutoSize = true,
|
||||||
|
Font = new Font("Segoe UI", 8f)
|
||||||
|
};
|
||||||
|
bendEditLink.LinkClicked += (s, e) =>
|
||||||
|
{
|
||||||
|
if (bendLinesList.SelectedIndex >= 0)
|
||||||
|
BendLineEdited?.Invoke(this, bendLinesList.SelectedIndex);
|
||||||
|
};
|
||||||
|
|
||||||
var bendDeleteLink = new LinkLabel
|
var bendDeleteLink = new LinkLabel
|
||||||
{
|
{
|
||||||
Text = "Remove",
|
Text = "Remove",
|
||||||
@@ -63,6 +76,12 @@ namespace OpenNest.Controls
|
|||||||
BendLineRemoved?.Invoke(this, bendLinesList.SelectedIndex);
|
BendLineRemoved?.Invoke(this, bendLinesList.SelectedIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bendLinesList.DoubleClick += (s, e) =>
|
||||||
|
{
|
||||||
|
if (bendLinesList.SelectedIndex >= 0)
|
||||||
|
BendLineEdited?.Invoke(this, bendLinesList.SelectedIndex);
|
||||||
|
};
|
||||||
|
|
||||||
bendAddLink = new LinkLabel
|
bendAddLink = new LinkLabel
|
||||||
{
|
{
|
||||||
Text = "Add Bend Line",
|
Text = "Add Bend Line",
|
||||||
@@ -80,6 +99,7 @@ namespace OpenNest.Controls
|
|||||||
WrapContents = false
|
WrapContents = false
|
||||||
};
|
};
|
||||||
bendLinksPanel.Controls.Add(bendAddLink);
|
bendLinksPanel.Controls.Add(bendAddLink);
|
||||||
|
bendLinksPanel.Controls.Add(bendEditLink);
|
||||||
bendLinksPanel.Controls.Add(bendDeleteLink);
|
bendLinksPanel.Controls.Add(bendDeleteLink);
|
||||||
|
|
||||||
bendLinesPanel.ContentPanel.Controls.Add(bendLinesList);
|
bendLinesPanel.ContentPanel.Controls.Add(bendLinesList);
|
||||||
|
|||||||
@@ -385,85 +385,20 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
private void DrawRapids(Graphics g)
|
private void DrawRapids(Graphics g)
|
||||||
{
|
{
|
||||||
|
var pen = view.ColorScheme.RapidPen;
|
||||||
var pos = new Vector(0, 0);
|
var pos = new Vector(0, 0);
|
||||||
|
|
||||||
for (var i = 0; i < view.Plate.Parts.Count; ++i)
|
for (var i = 0; i < view.Plate.Parts.Count; ++i)
|
||||||
{
|
{
|
||||||
var part = view.Plate.Parts[i];
|
var part = view.Plate.Parts[i];
|
||||||
var pgm = part.Program;
|
var segments = RapidEnumerator.Enumerate(part.Program, part.Location, pos);
|
||||||
|
|
||||||
var piercePoint = GetFirstPiercePoint(pgm, part.Location);
|
foreach (var seg in segments)
|
||||||
DrawLine(g, pos, piercePoint, view.ColorScheme.RapidPen);
|
|
||||||
|
|
||||||
pos = piercePoint;
|
|
||||||
DrawRapids(g, pgm, part.Location, ref pos, skipFirstRapid: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Vector GetFirstPiercePoint(Program pgm, Vector partLocation)
|
|
||||||
{
|
{
|
||||||
for (var i = 0; i < pgm.Length; i++)
|
DrawLine(g, seg.From, seg.To, pen);
|
||||||
{
|
pos = seg.To;
|
||||||
if (pgm[i] is SubProgramCall call && call.Program != null)
|
|
||||||
return GetFirstPiercePoint(call.Program, partLocation + call.Offset);
|
|
||||||
|
|
||||||
if (pgm[i] is Motion motion)
|
|
||||||
{
|
|
||||||
return motion.EndPoint + partLocation;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return partLocation;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawRapids(Graphics g, Program pgm, Vector basePos, ref Vector pos, bool skipFirstRapid = false)
|
|
||||||
{
|
|
||||||
var firstRapidSkipped = false;
|
|
||||||
|
|
||||||
for (var i = 0; i < pgm.Length; ++i)
|
|
||||||
{
|
|
||||||
var code = pgm[i];
|
|
||||||
|
|
||||||
if (code is SubProgramCall { Program: { } program } call)
|
|
||||||
{
|
|
||||||
// A SubProgramCall is a coordinate-frame shift, not a physical
|
|
||||||
// rapid to the hole center. The Cincinnati post emits it as a
|
|
||||||
// G52 bracket, so the physical rapid is the sub-program's first
|
|
||||||
// motion, which goes straight from here to the lead-in pierce.
|
|
||||||
// Look ahead for that pierce point and draw the direct rapid,
|
|
||||||
// then recurse with skipFirstRapid so the sub doesn't also draw
|
|
||||||
// its first rapid on top. See docs/cincinnati-post-output.md.
|
|
||||||
var holeBase = basePos + call.Offset;
|
|
||||||
var firstPierce = GetFirstPiercePoint(program, holeBase);
|
|
||||||
|
|
||||||
if (ShouldDrawRapid(skipFirstRapid, ref firstRapidSkipped))
|
|
||||||
DrawLine(g, pos, firstPierce, view.ColorScheme.RapidPen);
|
|
||||||
|
|
||||||
var subPos = holeBase;
|
|
||||||
DrawRapids(g, program, holeBase, ref subPos, skipFirstRapid: true);
|
|
||||||
pos = subPos;
|
|
||||||
}
|
|
||||||
else if (code is Motion motion)
|
|
||||||
{
|
|
||||||
var endpt = pgm.Mode == Mode.Incremental
|
|
||||||
? motion.EndPoint + pos
|
|
||||||
: motion.EndPoint;
|
|
||||||
|
|
||||||
if (code.Type == CodeType.RapidMove && ShouldDrawRapid(skipFirstRapid, ref firstRapidSkipped))
|
|
||||||
DrawLine(g, pos, endpt, view.ColorScheme.RapidPen);
|
|
||||||
|
|
||||||
pos = endpt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ShouldDrawRapid(bool skipFirstRapid, ref bool firstRapidSkipped)
|
|
||||||
{
|
|
||||||
if (skipFirstRapid && !firstRapidSkipped)
|
|
||||||
{
|
|
||||||
firstRapidSkipped = true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawAllPiercePoints(Graphics g)
|
private void DrawAllPiercePoints(Graphics g)
|
||||||
|
|||||||
@@ -464,6 +464,9 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
protected override void OnPaint(PaintEventArgs e)
|
protected override void OnPaint(PaintEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (BackColor != ColorScheme.BackgroundColor)
|
||||||
|
BackColor = ColorScheme.BackgroundColor;
|
||||||
|
|
||||||
e.Graphics.SmoothingMode = SmoothingMode.HighSpeed;
|
e.Graphics.SmoothingMode = SmoothingMode.HighSpeed;
|
||||||
|
|
||||||
if (DrawOrigin)
|
if (DrawOrigin)
|
||||||
|
|||||||
@@ -209,14 +209,7 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
private static Entity CloneEntity(Entity entity, Color color)
|
private static Entity CloneEntity(Entity entity, Color color)
|
||||||
{
|
{
|
||||||
Entity clone = entity switch
|
var clone = entity.Clone();
|
||||||
{
|
|
||||||
Line line => new Line(line.StartPoint, line.EndPoint) { Layer = line.Layer, IsVisible = line.IsVisible },
|
|
||||||
Arc arc => new Arc(arc.Center, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed) { Layer = arc.Layer, IsVisible = arc.IsVisible },
|
|
||||||
Circle circle => new Circle(circle.Center, circle.Radius) { Layer = circle.Layer, IsVisible = circle.IsVisible },
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
if (clone != null)
|
|
||||||
clone.Color = color;
|
clone.Color = color;
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,5 +99,17 @@ namespace OpenNest.Forms
|
|||||||
public double BendAngle => (double)numAngle.Value;
|
public double BendAngle => (double)numAngle.Value;
|
||||||
|
|
||||||
public double? BendRadius => chkRadius.Checked ? (double)numRadius.Value : null;
|
public double? BendRadius => chkRadius.Checked ? (double)numRadius.Value : null;
|
||||||
|
|
||||||
|
public void LoadBend(Bend bend)
|
||||||
|
{
|
||||||
|
cboDirection.SelectedIndex = bend.Direction == BendDirection.Up ? 1 : 0;
|
||||||
|
if (bend.Angle.HasValue)
|
||||||
|
numAngle.Value = (decimal)bend.Angle.Value;
|
||||||
|
if (bend.Radius.HasValue)
|
||||||
|
{
|
||||||
|
chkRadius.Checked = true;
|
||||||
|
numRadius.Value = (decimal)bend.Radius.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ namespace OpenNest.Forms
|
|||||||
filterPanel.FilterChanged += OnFilterChanged;
|
filterPanel.FilterChanged += OnFilterChanged;
|
||||||
filterPanel.BendLineSelected += OnBendLineSelected;
|
filterPanel.BendLineSelected += OnBendLineSelected;
|
||||||
filterPanel.BendLineRemoved += OnBendLineRemoved;
|
filterPanel.BendLineRemoved += OnBendLineRemoved;
|
||||||
|
filterPanel.BendLineEdited += OnBendLineEdited;
|
||||||
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
|
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
|
||||||
entityView1.LinePicked += OnLinePicked;
|
entityView1.LinePicked += OnLinePicked;
|
||||||
entityView1.PickCancelled += OnPickCancelled;
|
entityView1.PickCancelled += OnPickCancelled;
|
||||||
@@ -292,6 +293,29 @@ namespace OpenNest.Forms
|
|||||||
entityView1.Invalidate();
|
entityView1.Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnBendLineEdited(object sender, int index)
|
||||||
|
{
|
||||||
|
var item = CurrentItem;
|
||||||
|
if (item == null || index < 0 || index >= item.Bends.Count) return;
|
||||||
|
|
||||||
|
var bend = item.Bends[index];
|
||||||
|
using var dialog = new BendLineDialog();
|
||||||
|
dialog.LoadBend(bend);
|
||||||
|
|
||||||
|
if (dialog.ShowDialog(this) != DialogResult.OK) return;
|
||||||
|
|
||||||
|
bend.Direction = dialog.Direction;
|
||||||
|
bend.Angle = dialog.BendAngle;
|
||||||
|
bend.Radius = dialog.BendRadius;
|
||||||
|
|
||||||
|
Bend.UpdateEtchEntities(item.Entities, item.Bends);
|
||||||
|
entityView1.Entities.Clear();
|
||||||
|
entityView1.Entities.AddRange(item.Entities);
|
||||||
|
entityView1.Bends = item.Bends;
|
||||||
|
filterPanel.LoadItem(item.Entities, item.Bends);
|
||||||
|
entityView1.Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnQuantityChanged(object sender, EventArgs e)
|
private void OnQuantityChanged(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
var item = CurrentItem;
|
var item = CurrentItem;
|
||||||
|
|||||||
Generated
+11
-20
@@ -47,11 +47,9 @@
|
|||||||
drawingListBox1 = new OpenNest.Controls.DrawingListBox();
|
drawingListBox1 = new OpenNest.Controls.DrawingListBox();
|
||||||
toolStrip2 = new System.Windows.Forms.ToolStrip();
|
toolStrip2 = new System.Windows.Forms.ToolStrip();
|
||||||
toolStripButton2 = new System.Windows.Forms.ToolStripButton();
|
toolStripButton2 = new System.Windows.Forms.ToolStripButton();
|
||||||
toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator();
|
shapeLibraryButton = new System.Windows.Forms.ToolStripButton();
|
||||||
editDrawingsButton = new System.Windows.Forms.ToolStripButton();
|
editDrawingsButton = new System.Windows.Forms.ToolStripButton();
|
||||||
toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator();
|
|
||||||
toolStripButton3 = new System.Windows.Forms.ToolStripButton();
|
toolStripButton3 = new System.Windows.Forms.ToolStripButton();
|
||||||
toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator();
|
|
||||||
hideNestedButton = new System.Windows.Forms.ToolStripButton();
|
hideNestedButton = new System.Windows.Forms.ToolStripButton();
|
||||||
((System.ComponentModel.ISupportInitialize)splitContainer).BeginInit();
|
((System.ComponentModel.ISupportInitialize)splitContainer).BeginInit();
|
||||||
splitContainer.Panel1.SuspendLayout();
|
splitContainer.Panel1.SuspendLayout();
|
||||||
@@ -219,7 +217,7 @@
|
|||||||
//
|
//
|
||||||
toolStrip2.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden;
|
toolStrip2.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden;
|
||||||
toolStrip2.ImageScalingSize = new System.Drawing.Size(20, 20);
|
toolStrip2.ImageScalingSize = new System.Drawing.Size(20, 20);
|
||||||
toolStrip2.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { toolStripButton2, toolStripSeparator4, editDrawingsButton, toolStripSeparator1, toolStripButton3, toolStripSeparator2, hideNestedButton });
|
toolStrip2.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { toolStripButton2, shapeLibraryButton, editDrawingsButton, toolStripButton3, hideNestedButton });
|
||||||
toolStrip2.Location = new System.Drawing.Point(4, 3);
|
toolStrip2.Location = new System.Drawing.Point(4, 3);
|
||||||
toolStrip2.Name = "toolStrip2";
|
toolStrip2.Name = "toolStrip2";
|
||||||
toolStrip2.Size = new System.Drawing.Size(265, 27);
|
toolStrip2.Size = new System.Drawing.Size(265, 27);
|
||||||
@@ -238,10 +236,15 @@
|
|||||||
toolStripButton2.Text = "Import Drawings";
|
toolStripButton2.Text = "Import Drawings";
|
||||||
toolStripButton2.Click += ImportDrawings_Click;
|
toolStripButton2.Click += ImportDrawings_Click;
|
||||||
//
|
//
|
||||||
// toolStripSeparator4
|
// shapeLibraryButton
|
||||||
//
|
//
|
||||||
toolStripSeparator4.Name = "toolStripSeparator4";
|
shapeLibraryButton.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
|
||||||
toolStripSeparator4.Size = new System.Drawing.Size(6, 27);
|
shapeLibraryButton.Image = Properties.Resources.shapes;
|
||||||
|
shapeLibraryButton.Name = "shapeLibraryButton";
|
||||||
|
shapeLibraryButton.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
|
||||||
|
shapeLibraryButton.Size = new System.Drawing.Size(34, 24);
|
||||||
|
shapeLibraryButton.Text = "Shape Library";
|
||||||
|
shapeLibraryButton.Click += ShapeLibrary_Click;
|
||||||
//
|
//
|
||||||
// editDrawingsButton
|
// editDrawingsButton
|
||||||
//
|
//
|
||||||
@@ -253,11 +256,6 @@
|
|||||||
editDrawingsButton.Text = "Edit Drawings in Converter";
|
editDrawingsButton.Text = "Edit Drawings in Converter";
|
||||||
editDrawingsButton.Click += EditDrawingsInConverter_Click;
|
editDrawingsButton.Click += EditDrawingsInConverter_Click;
|
||||||
//
|
//
|
||||||
// toolStripSeparator1
|
|
||||||
//
|
|
||||||
toolStripSeparator1.Name = "toolStripSeparator1";
|
|
||||||
toolStripSeparator1.Size = new System.Drawing.Size(6, 27);
|
|
||||||
//
|
|
||||||
// toolStripButton3
|
// toolStripButton3
|
||||||
//
|
//
|
||||||
toolStripButton3.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
|
toolStripButton3.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
|
||||||
@@ -269,11 +267,6 @@
|
|||||||
toolStripButton3.Text = "Cleanup unused Drawings";
|
toolStripButton3.Text = "Cleanup unused Drawings";
|
||||||
toolStripButton3.Click += CleanUnusedDrawings_Click;
|
toolStripButton3.Click += CleanUnusedDrawings_Click;
|
||||||
//
|
//
|
||||||
// toolStripSeparator2
|
|
||||||
//
|
|
||||||
toolStripSeparator2.Name = "toolStripSeparator2";
|
|
||||||
toolStripSeparator2.Size = new System.Drawing.Size(6, 27);
|
|
||||||
//
|
|
||||||
// hideNestedButton
|
// hideNestedButton
|
||||||
//
|
//
|
||||||
hideNestedButton.CheckOnClick = true;
|
hideNestedButton.CheckOnClick = true;
|
||||||
@@ -329,11 +322,9 @@
|
|||||||
private System.Windows.Forms.ColumnHeader utilColumn;
|
private System.Windows.Forms.ColumnHeader utilColumn;
|
||||||
private System.Windows.Forms.ToolStrip toolStrip2;
|
private System.Windows.Forms.ToolStrip toolStrip2;
|
||||||
private System.Windows.Forms.ToolStripButton toolStripButton2;
|
private System.Windows.Forms.ToolStripButton toolStripButton2;
|
||||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
|
private System.Windows.Forms.ToolStripButton shapeLibraryButton;
|
||||||
private System.Windows.Forms.ToolStripButton editDrawingsButton;
|
private System.Windows.Forms.ToolStripButton editDrawingsButton;
|
||||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator1;
|
|
||||||
private System.Windows.Forms.ToolStripButton toolStripButton3;
|
private System.Windows.Forms.ToolStripButton toolStripButton3;
|
||||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator2;
|
|
||||||
private System.Windows.Forms.ToolStripButton hideNestedButton;
|
private System.Windows.Forms.ToolStripButton hideNestedButton;
|
||||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator3;
|
private System.Windows.Forms.ToolStripSeparator toolStripSeparator3;
|
||||||
private System.Windows.Forms.ToolStripButton toolStripLabel1;
|
private System.Windows.Forms.ToolStripButton toolStripLabel1;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using OpenNest.Engine.Sequencing;
|
|||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using OpenNest.Properties;
|
using OpenNest.Properties;
|
||||||
|
using OpenNest.Shapes;
|
||||||
using System;
|
using System;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
@@ -453,7 +454,11 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
public void ResizePlateToFitParts()
|
public void ResizePlateToFitParts()
|
||||||
{
|
{
|
||||||
PlateView.Plate.AutoSize(Settings.Default.AutoSizePlateFactor);
|
var options = new PlateSizeOptions
|
||||||
|
{
|
||||||
|
SnapIncrement = Settings.Default.AutoSizePlateFactor,
|
||||||
|
};
|
||||||
|
PlateView.Plate.SnapToStandardSize(options);
|
||||||
PlateView.ZoomToPlate();
|
PlateView.ZoomToPlate();
|
||||||
PlateView.Refresh();
|
PlateView.Refresh();
|
||||||
UpdatePlateList();
|
UpdatePlateList();
|
||||||
@@ -870,6 +875,18 @@ namespace OpenNest.Forms
|
|||||||
Import();
|
Import();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ShapeLibrary_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var form = new ShapeLibraryForm(Nest.Drawings.Select(d => d.Name));
|
||||||
|
form.ShowDialog();
|
||||||
|
|
||||||
|
var drawings = form.GetDrawings();
|
||||||
|
if (drawings.Count == 0) return;
|
||||||
|
|
||||||
|
drawings.ForEach(d => Nest.Drawings.Add(d));
|
||||||
|
UpdateDrawingList();
|
||||||
|
}
|
||||||
|
|
||||||
private void EditDrawingsInConverter_Click(object sender, EventArgs e)
|
private void EditDrawingsInConverter_Click(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (Nest.Drawings.Count == 0)
|
if (Nest.Drawings.Count == 0)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ namespace OpenNest.Forms
|
|||||||
NestEngineRegistry.LoadPlugins(enginesDir);
|
NestEngineRegistry.LoadPlugins(enginesDir);
|
||||||
|
|
||||||
OptionsForm.ApplyDisabledStrategies();
|
OptionsForm.ApplyDisabledStrategies();
|
||||||
|
ColorSchemeRegistry.ApplyActiveFromSettings();
|
||||||
|
|
||||||
foreach (var engine in NestEngineRegistry.AvailableEngines)
|
foreach (var engine in NestEngineRegistry.AvailableEngines)
|
||||||
engineComboBox.Items.Add(engine.Name);
|
engineComboBox.Items.Add(engine.Name);
|
||||||
@@ -836,7 +837,7 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
if (activeForm == null) return;
|
if (activeForm == null) return;
|
||||||
|
|
||||||
var form = new ShapeLibraryForm();
|
var form = new ShapeLibraryForm(activeForm.Nest.Drawings.Select(d => d.Name));
|
||||||
form.ShowDialog();
|
form.ShowDialog();
|
||||||
|
|
||||||
var drawings = form.GetDrawings();
|
var drawings = form.GetDrawings();
|
||||||
|
|||||||
Generated
+31
-5
@@ -42,6 +42,8 @@
|
|||||||
this.bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
this.bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
||||||
this.strategyGrid = new System.Windows.Forms.DataGridView();
|
this.strategyGrid = new System.Windows.Forms.DataGridView();
|
||||||
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
|
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
|
||||||
|
this.colorSchemeLabel = new System.Windows.Forms.Label();
|
||||||
|
this.colorSchemeCombo = new System.Windows.Forms.ComboBox();
|
||||||
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
|
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
|
||||||
this.tableLayoutPanel1.SuspendLayout();
|
this.tableLayoutPanel1.SuspendLayout();
|
||||||
this.bottomPanel1.SuspendLayout();
|
this.bottomPanel1.SuspendLayout();
|
||||||
@@ -95,15 +97,18 @@
|
|||||||
this.tableLayoutPanel1.Controls.Add(this.label1, 0, 1);
|
this.tableLayoutPanel1.Controls.Add(this.label1, 0, 1);
|
||||||
this.tableLayoutPanel1.Controls.Add(this.textBox1, 1, 0);
|
this.tableLayoutPanel1.Controls.Add(this.textBox1, 1, 0);
|
||||||
this.tableLayoutPanel1.Controls.Add(this.label3, 0, 0);
|
this.tableLayoutPanel1.Controls.Add(this.label3, 0, 0);
|
||||||
this.tableLayoutPanel1.Controls.Add(this.checkBox1, 0, 2);
|
this.tableLayoutPanel1.Controls.Add(this.colorSchemeLabel, 0, 2);
|
||||||
|
this.tableLayoutPanel1.Controls.Add(this.colorSchemeCombo, 1, 2);
|
||||||
|
this.tableLayoutPanel1.Controls.Add(this.checkBox1, 0, 3);
|
||||||
this.tableLayoutPanel1.Controls.Add(this.numericUpDown1, 1, 1);
|
this.tableLayoutPanel1.Controls.Add(this.numericUpDown1, 1, 1);
|
||||||
this.tableLayoutPanel1.Controls.Add(this.button1, 3, 0);
|
this.tableLayoutPanel1.Controls.Add(this.button1, 3, 0);
|
||||||
this.tableLayoutPanel1.Location = new System.Drawing.Point(12, 12);
|
this.tableLayoutPanel1.Location = new System.Drawing.Point(12, 12);
|
||||||
this.tableLayoutPanel1.Name = "tableLayoutPanel1";
|
this.tableLayoutPanel1.Name = "tableLayoutPanel1";
|
||||||
this.tableLayoutPanel1.RowCount = 3;
|
this.tableLayoutPanel1.RowCount = 4;
|
||||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
|
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
|
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.34F));
|
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||||
|
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||||
this.tableLayoutPanel1.Size = new System.Drawing.Size(684, 160);
|
this.tableLayoutPanel1.Size = new System.Drawing.Size(684, 160);
|
||||||
this.tableLayoutPanel1.TabIndex = 0;
|
this.tableLayoutPanel1.TabIndex = 0;
|
||||||
//
|
//
|
||||||
@@ -198,6 +203,25 @@
|
|||||||
this.strategyGroupBox.TabStop = false;
|
this.strategyGroupBox.TabStop = false;
|
||||||
this.strategyGroupBox.Text = "Fill Strategies";
|
this.strategyGroupBox.Text = "Fill Strategies";
|
||||||
//
|
//
|
||||||
|
// colorSchemeLabel
|
||||||
|
//
|
||||||
|
this.colorSchemeLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||||
|
this.colorSchemeLabel.AutoSize = true;
|
||||||
|
this.colorSchemeLabel.Location = new System.Drawing.Point(3, 92);
|
||||||
|
this.colorSchemeLabel.Name = "colorSchemeLabel";
|
||||||
|
this.colorSchemeLabel.Size = new System.Drawing.Size(145, 16);
|
||||||
|
this.colorSchemeLabel.TabIndex = 10;
|
||||||
|
this.colorSchemeLabel.Text = "Color scheme:";
|
||||||
|
//
|
||||||
|
// colorSchemeCombo
|
||||||
|
//
|
||||||
|
this.colorSchemeCombo.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||||
|
this.colorSchemeCombo.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||||
|
this.colorSchemeCombo.Location = new System.Drawing.Point(154, 89);
|
||||||
|
this.colorSchemeCombo.Name = "colorSchemeCombo";
|
||||||
|
this.colorSchemeCombo.Size = new System.Drawing.Size(130, 24);
|
||||||
|
this.colorSchemeCombo.TabIndex = 11;
|
||||||
|
//
|
||||||
// OptionsForm
|
// OptionsForm
|
||||||
//
|
//
|
||||||
this.AcceptButton = this.saveButton;
|
this.AcceptButton = this.saveButton;
|
||||||
@@ -239,5 +263,7 @@
|
|||||||
private System.Windows.Forms.Button button1;
|
private System.Windows.Forms.Button button1;
|
||||||
private System.Windows.Forms.DataGridView strategyGrid;
|
private System.Windows.Forms.DataGridView strategyGrid;
|
||||||
private System.Windows.Forms.GroupBox strategyGroupBox;
|
private System.Windows.Forms.GroupBox strategyGroupBox;
|
||||||
|
private System.Windows.Forms.Label colorSchemeLabel;
|
||||||
|
private System.Windows.Forms.ComboBox colorSchemeCombo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +68,13 @@ namespace OpenNest.Forms
|
|||||||
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
||||||
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
|
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
|
||||||
|
|
||||||
|
colorSchemeCombo.Items.Clear();
|
||||||
|
foreach (var scheme in ColorSchemeRegistry.AllSchemes)
|
||||||
|
colorSchemeCombo.Items.Add(scheme.Name);
|
||||||
|
var active = Settings.Default.ActiveColorScheme;
|
||||||
|
var idx = colorSchemeCombo.Items.IndexOf(active);
|
||||||
|
colorSchemeCombo.SelectedIndex = idx >= 0 ? idx : 0;
|
||||||
|
|
||||||
var disabledNames = ParseDisabledStrategies(Settings.Default.DisabledStrategies);
|
var disabledNames = ParseDisabledStrategies(Settings.Default.DisabledStrategies);
|
||||||
foreach (DataGridViewRow row in strategyGrid.Rows)
|
foreach (DataGridViewRow row in strategyGrid.Rows)
|
||||||
row.Cells["Enabled"].Value = !disabledNames.Contains((string)row.Cells["Name"].Value);
|
row.Cells["Enabled"].Value = !disabledNames.Contains((string)row.Cells["Name"].Value);
|
||||||
@@ -78,6 +85,7 @@ namespace OpenNest.Forms
|
|||||||
Settings.Default.NestTemplatePath = textBox1.Text;
|
Settings.Default.NestTemplatePath = textBox1.Text;
|
||||||
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
|
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
|
||||||
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
|
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
|
||||||
|
Settings.Default.ActiveColorScheme = colorSchemeCombo.SelectedItem as string ?? "Classic";
|
||||||
|
|
||||||
var disabledNames = new List<string>();
|
var disabledNames = new List<string>();
|
||||||
foreach (DataGridViewRow row in strategyGrid.Rows)
|
foreach (DataGridViewRow row in strategyGrid.Rows)
|
||||||
@@ -89,6 +97,10 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
Settings.Default.Save();
|
Settings.Default.Save();
|
||||||
ApplyDisabledStrategies();
|
ApplyDisabledStrategies();
|
||||||
|
ColorSchemeRegistry.ApplyActiveFromSettings();
|
||||||
|
|
||||||
|
foreach (Form f in Application.OpenForms)
|
||||||
|
f.Invalidate(invalidateChildren: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -21,12 +21,17 @@ namespace OpenNest.Forms
|
|||||||
private readonly List<Drawing> addedDrawings = new List<Drawing>();
|
private readonly List<Drawing> addedDrawings = new List<Drawing>();
|
||||||
private readonly List<ShapeEntry> shapeEntries = new List<ShapeEntry>();
|
private readonly List<ShapeEntry> shapeEntries = new List<ShapeEntry>();
|
||||||
private readonly List<ParameterBinding> parameterBindings = new List<ParameterBinding>();
|
private readonly List<ParameterBinding> parameterBindings = new List<ParameterBinding>();
|
||||||
|
private readonly HashSet<string> existingNames;
|
||||||
|
|
||||||
private ShapeEntry selectedEntry;
|
private ShapeEntry selectedEntry;
|
||||||
private bool suppressPreview;
|
private bool suppressPreview;
|
||||||
|
|
||||||
public ShapeLibraryForm()
|
public ShapeLibraryForm(IEnumerable<string> existingDrawingNames = null)
|
||||||
{
|
{
|
||||||
|
existingNames = existingDrawingNames != null
|
||||||
|
? new HashSet<string>(existingDrawingNames, StringComparer.OrdinalIgnoreCase)
|
||||||
|
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DiscoverShapes();
|
DiscoverShapes();
|
||||||
PopulateShapeList();
|
PopulateShapeList();
|
||||||
@@ -180,6 +185,43 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
y += 18;
|
y += 18;
|
||||||
|
|
||||||
|
Control editor;
|
||||||
|
if (prop.PropertyType == typeof(bool))
|
||||||
|
{
|
||||||
|
var cb = new CheckBox
|
||||||
|
{
|
||||||
|
Location = new Point(parametersPanel.Padding.Left, y),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = sourceValues != null && (bool)prop.GetValue(sourceValues)
|
||||||
|
};
|
||||||
|
cb.CheckedChanged += (s, ev) => UpdatePreview();
|
||||||
|
editor = cb;
|
||||||
|
}
|
||||||
|
else if (prop.PropertyType == typeof(string) && prop.Name == "PipeSize")
|
||||||
|
{
|
||||||
|
var combo = new ComboBox
|
||||||
|
{
|
||||||
|
Location = new Point(parametersPanel.Padding.Left, y),
|
||||||
|
Width = panelWidth,
|
||||||
|
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
|
||||||
|
DropDownStyle = ComboBoxStyle.DropDownList
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial population: every entry; the filter runs on first UpdatePreview.
|
||||||
|
foreach (var entry in PipeSizes.All)
|
||||||
|
combo.Items.Add(entry.Label);
|
||||||
|
|
||||||
|
var initial = sourceValues != null ? (string)prop.GetValue(sourceValues) : null;
|
||||||
|
if (!string.IsNullOrEmpty(initial) && combo.Items.Contains(initial))
|
||||||
|
combo.SelectedItem = initial;
|
||||||
|
else if (combo.Items.Count > 0)
|
||||||
|
combo.SelectedIndex = 0;
|
||||||
|
|
||||||
|
combo.SelectedIndexChanged += (s, ev) => UpdatePreview();
|
||||||
|
editor = combo;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
var tb = new TextBox
|
var tb = new TextBox
|
||||||
{
|
{
|
||||||
Location = new Point(parametersPanel.Padding.Left, y),
|
Location = new Point(parametersPanel.Padding.Left, y),
|
||||||
@@ -196,11 +238,13 @@ namespace OpenNest.Forms
|
|||||||
}
|
}
|
||||||
|
|
||||||
tb.TextChanged += (s, ev) => UpdatePreview();
|
tb.TextChanged += (s, ev) => UpdatePreview();
|
||||||
|
editor = tb;
|
||||||
|
}
|
||||||
|
|
||||||
parameterBindings.Add(new ParameterBinding { Property = prop, Control = tb });
|
parameterBindings.Add(new ParameterBinding { Property = prop, Control = editor });
|
||||||
|
|
||||||
parametersPanel.Controls.Add(label);
|
parametersPanel.Controls.Add(label);
|
||||||
parametersPanel.Controls.Add(tb);
|
parametersPanel.Controls.Add(editor);
|
||||||
|
|
||||||
y += 30;
|
y += 30;
|
||||||
}
|
}
|
||||||
@@ -212,20 +256,31 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
if (suppressPreview || selectedEntry == null) return;
|
if (suppressPreview || selectedEntry == null) return;
|
||||||
|
|
||||||
|
UpdatePipeSizeFilter();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var shape = CreateShapeFromInputs();
|
var shape = CreateShapeFromInputs();
|
||||||
if (shape == null) return;
|
if (shape == null) return;
|
||||||
|
|
||||||
var drawing = shape.GetDrawing();
|
var drawing = shape.GetDrawing();
|
||||||
|
nameTextBox.Text = shape.GenerateName();
|
||||||
previewBox.ShowDrawing(drawing);
|
previewBox.ShowDrawing(drawing);
|
||||||
|
|
||||||
if (drawing?.Program != null)
|
if (drawing?.Program != null)
|
||||||
{
|
{
|
||||||
var bb = drawing.Program.BoundingBox();
|
var bb = drawing.Program.BoundingBox();
|
||||||
previewBox.SetInfo(
|
var info = string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width);
|
||||||
nameTextBox.Text,
|
|
||||||
string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width));
|
if (shape is PipeFlangeShape flange
|
||||||
|
&& !flange.Blind
|
||||||
|
&& !string.IsNullOrEmpty(flange.PipeSize)
|
||||||
|
&& !PipeSizes.TryGetOD(flange.PipeSize, out _))
|
||||||
|
{
|
||||||
|
info += " — Invalid pipe size, no bore cut";
|
||||||
|
}
|
||||||
|
|
||||||
|
previewBox.SetInfo(nameTextBox.Text, info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -234,6 +289,72 @@ namespace OpenNest.Forms
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdatePipeSizeFilter()
|
||||||
|
{
|
||||||
|
// Find the PipeSize combo and the numeric inputs it depends on.
|
||||||
|
ComboBox pipeCombo = null;
|
||||||
|
double holePattern = 0, holeDia = 0, clearance = 0;
|
||||||
|
bool blind = false;
|
||||||
|
|
||||||
|
foreach (var binding in parameterBindings)
|
||||||
|
{
|
||||||
|
var name = binding.Property.Name;
|
||||||
|
if (name == "PipeSize" && binding.Control is ComboBox cb)
|
||||||
|
pipeCombo = cb;
|
||||||
|
else if (name == "HolePatternDiameter" && binding.Control is TextBox tb1)
|
||||||
|
double.TryParse(tb1.Text, out holePattern);
|
||||||
|
else if (name == "HoleDiameter" && binding.Control is TextBox tb2)
|
||||||
|
double.TryParse(tb2.Text, out holeDia);
|
||||||
|
else if (name == "PipeClearance" && binding.Control is TextBox tb3)
|
||||||
|
double.TryParse(tb3.Text, out clearance);
|
||||||
|
else if (name == "Blind" && binding.Control is CheckBox chk)
|
||||||
|
blind = chk.Checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pipeCombo == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Disable when blind, but keep visible with the selection preserved.
|
||||||
|
pipeCombo.Enabled = !blind;
|
||||||
|
|
||||||
|
// Compute filter: pipeOD + clearance < HolePatternDiameter - HoleDiameter.
|
||||||
|
var maxPipeOD = holePattern - holeDia - clearance;
|
||||||
|
var fittingLabels = PipeSizes.GetFittingSizes(maxPipeOD).Select(e => e.Label).ToList();
|
||||||
|
|
||||||
|
// Sequence-equal on existing items — no-op if unchanged (avoids flicker).
|
||||||
|
var currentLabels = pipeCombo.Items.Cast<string>().ToList();
|
||||||
|
if (currentLabels.SequenceEqual(fittingLabels))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var previousSelection = pipeCombo.SelectedItem as string;
|
||||||
|
|
||||||
|
pipeCombo.BeginUpdate();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipeCombo.Items.Clear();
|
||||||
|
foreach (var label in fittingLabels)
|
||||||
|
pipeCombo.Items.Add(label);
|
||||||
|
|
||||||
|
if (fittingLabels.Count == 0)
|
||||||
|
{
|
||||||
|
// No pipe fits — leave unselected.
|
||||||
|
}
|
||||||
|
else if (previousSelection != null && fittingLabels.Contains(previousSelection))
|
||||||
|
{
|
||||||
|
pipeCombo.SelectedItem = previousSelection;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Select the largest (last, since PipeSizes.All is sorted ascending).
|
||||||
|
pipeCombo.SelectedIndex = fittingLabels.Count - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
pipeCombo.EndUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ShapeDefinition CreateShapeFromInputs()
|
private ShapeDefinition CreateShapeFromInputs()
|
||||||
{
|
{
|
||||||
var shape = (ShapeDefinition)Activator.CreateInstance(selectedEntry.ShapeType);
|
var shape = (ShapeDefinition)Activator.CreateInstance(selectedEntry.ShapeType);
|
||||||
@@ -241,6 +362,19 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
foreach (var binding in parameterBindings)
|
foreach (var binding in parameterBindings)
|
||||||
{
|
{
|
||||||
|
if (binding.Property.PropertyType == typeof(bool))
|
||||||
|
{
|
||||||
|
var cb = (CheckBox)binding.Control;
|
||||||
|
binding.Property.SetValue(shape, cb.Checked);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.Control is ComboBox combo)
|
||||||
|
{
|
||||||
|
binding.Property.SetValue(shape, combo.SelectedItem?.ToString());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var tb = (TextBox)binding.Control;
|
var tb = (TextBox)binding.Control;
|
||||||
|
|
||||||
if (binding.Property.PropertyType == typeof(int))
|
if (binding.Property.PropertyType == typeof(int))
|
||||||
@@ -277,10 +411,12 @@ namespace OpenNest.Forms
|
|||||||
if (shape == null) return;
|
if (shape == null) return;
|
||||||
|
|
||||||
var drawing = shape.GetDrawing();
|
var drawing = shape.GetDrawing();
|
||||||
|
drawing.Name = GetUniqueName(drawing.Name);
|
||||||
drawing.Color = Drawing.GetNextColor();
|
drawing.Color = Drawing.GetNextColor();
|
||||||
drawing.Quantity.Required = (int)quantityUpDown.Value;
|
drawing.Quantity.Required = (int)quantityUpDown.Value;
|
||||||
|
|
||||||
addedDrawings.Add(drawing);
|
addedDrawings.Add(drawing);
|
||||||
|
existingNames.Add(drawing.Name);
|
||||||
DialogResult = DialogResult.OK;
|
DialogResult = DialogResult.OK;
|
||||||
|
|
||||||
addButton.Text = $"Added ({addedDrawings.Count})";
|
addButton.Text = $"Added ({addedDrawings.Count})";
|
||||||
@@ -295,6 +431,19 @@ namespace OpenNest.Forms
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetUniqueName(string baseName)
|
||||||
|
{
|
||||||
|
if (!existingNames.Contains(baseName))
|
||||||
|
return baseName;
|
||||||
|
|
||||||
|
for (var i = 2; ; i++)
|
||||||
|
{
|
||||||
|
var candidate = $"{baseName} ({i})";
|
||||||
|
if (!existingNames.Contains(candidate))
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string FriendlyName(string name)
|
private static string FriendlyName(string name)
|
||||||
{
|
{
|
||||||
if (name.EndsWith("Shape"))
|
if (name.EndsWith("Shape"))
|
||||||
|
|||||||
@@ -138,9 +138,20 @@ namespace OpenNest
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case CodeType.RapidMove:
|
case CodeType.RapidMove:
|
||||||
|
{
|
||||||
|
var rapid = (RapidMove)code;
|
||||||
|
var endpt = rapid.EndPoint;
|
||||||
|
if (mode == Mode.Incremental)
|
||||||
|
endpt += curpos;
|
||||||
|
var dx = endpt.X - curpos.X;
|
||||||
|
var dy = endpt.Y - curpos.Y;
|
||||||
|
if (dx * dx + dy * dy > 0.001 * 0.001)
|
||||||
|
{
|
||||||
cutPath.StartFigure();
|
cutPath.StartFigure();
|
||||||
leadPath.StartFigure();
|
leadPath.StartFigure();
|
||||||
AddLine(cutPath, (RapidMove)code, mode, ref curpos);
|
}
|
||||||
|
curpos = endpt;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CodeType.SubProgramCall:
|
case CodeType.SubProgramCall:
|
||||||
@@ -300,8 +311,17 @@ namespace OpenNest
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case CodeType.RapidMove:
|
case CodeType.RapidMove:
|
||||||
|
{
|
||||||
|
var rapid = (RapidMove)code;
|
||||||
|
var endpt = rapid.EndPoint;
|
||||||
|
if (mode == Mode.Incremental)
|
||||||
|
endpt += curpos;
|
||||||
|
var dx = endpt.X - curpos.X;
|
||||||
|
var dy = endpt.Y - curpos.Y;
|
||||||
|
if (dx * dx + dy * dy > 0.001 * 0.001)
|
||||||
Flush();
|
Flush();
|
||||||
AddLine(path, (RapidMove)code, mode, ref curpos);
|
curpos = endpt;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CodeType.SubProgramCall:
|
case CodeType.SubProgramCall:
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Schemes\*.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
|
|||||||
+10
@@ -250,6 +250,16 @@ namespace OpenNest.Properties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized resource of type System.Drawing.Bitmap.
|
||||||
|
/// </summary>
|
||||||
|
internal static System.Drawing.Bitmap shapes {
|
||||||
|
get {
|
||||||
|
object obj = ResourceManager.GetObject("shapes", resourceCulture);
|
||||||
|
return ((System.Drawing.Bitmap)(obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized resource of type System.Drawing.Bitmap.
|
/// Looks up a localized resource of type System.Drawing.Bitmap.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -187,4 +187,7 @@
|
|||||||
<data name="delete" type="System.Resources.ResXFileRef, System.Windows.Forms">
|
<data name="delete" type="System.Resources.ResXFileRef, System.Windows.Forms">
|
||||||
<value>..\Resources\delete.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
|
<value>..\Resources\delete.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="shapes" type="System.Resources.ResXFileRef, System.Windows.Forms">
|
||||||
|
<value>..\Resources\shapes.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
Generated
+12
@@ -226,5 +226,17 @@ namespace OpenNest.Properties {
|
|||||||
this["CuttingParametersJson"] = value;
|
this["CuttingParametersJson"] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||||
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||||
|
[global::System.Configuration.DefaultSettingValueAttribute("Classic")]
|
||||||
|
public string ActiveColorScheme {
|
||||||
|
get {
|
||||||
|
return ((string)(this["ActiveColorScheme"]));
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
this["ActiveColorScheme"] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,5 +53,8 @@
|
|||||||
<Setting Name="CuttingParametersJson" Type="System.String" Scope="User">
|
<Setting Name="CuttingParametersJson" Type="System.String" Scope="User">
|
||||||
<Value Profile="(Default)" />
|
<Value Profile="(Default)" />
|
||||||
</Setting>
|
</Setting>
|
||||||
|
<Setting Name="ActiveColorScheme" Type="System.String" Scope="User">
|
||||||
|
<Value Profile="(Default)">Classic</Value>
|
||||||
|
</Setting>
|
||||||
</Settings>
|
</Settings>
|
||||||
</SettingsFile>
|
</SettingsFile>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -2,32 +2,52 @@
|
|||||||
|
|
||||||
A Windows desktop application for CNC nesting — imports DXF drawings, arranges parts on material plates, and exports layouts as DXF or G-code for cutting.
|
A Windows desktop application for CNC nesting — imports DXF drawings, arranges parts on material plates, and exports layouts as DXF or G-code for cutting.
|
||||||
|
|
||||||

|
<p>
|
||||||
|
<a href="screenshots/screenshot-nest-1.png"><img src="screenshots/screenshot-nest-1.png" width="420" alt="OpenNest - parts nested on a 36x36 plate"></a>
|
||||||
|
<a href="screenshots/screenshot-nest-2.png"><img src="screenshots/screenshot-nest-2.png" width="420" alt="OpenNest - 44 parts nested on a 60x120 plate"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and arranges the parts to make efficient use of material. The result can be exported as DXF files or post-processed into G-code that your CNC cutting machine understands.
|
OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and arranges the parts to make efficient use of material. The result can be exported as DXF files or post-processed into G-code that your CNC cutting machine understands.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **DXF/DWG Import & Export** — Load part drawings from DXF or DWG files and export completed nest layouts as DXF
|
### Import & Export
|
||||||
- **Multiple Fill Strategies** — Grid-based linear fill, interlocking pair fill, rectangle bin packing, extents-based tiling, and more via a pluggable strategy system
|
|
||||||
- **Best-Fit Pair Nesting** — NFP-based (No Fit Polygon) pair evaluation finds tight-fitting interlocking orientations between parts
|
|
||||||
- **GPU Acceleration** — Optional ILGPU-based bitmap overlap detection for faster best-fit evaluation
|
|
||||||
- **Part Rotation** — Automatically tries different rotation angles to find better fits, with optional ML-based angle prediction (ONNX)
|
|
||||||
- **Gravity Compaction** — After placing parts, pushes them together using polygon-based directional distance to close gaps between irregular shapes
|
|
||||||
- **Multi-Plate Support** — Work with multiple plates of different sizes and materials in a single nest
|
|
||||||
- **Sheet Cut-Offs** — Automatically cut the plate to size after nesting, with geometry-aware clearance that avoids placed parts
|
|
||||||
- **Drawing Splitting** — Split oversized parts into pieces that fit your plate, with straight cuts, weld-gap tabs, or interlocking spike-groove joints
|
|
||||||
- **BOM Import** — Read bills of materials from Excel spreadsheets to batch-import part lists with quantities
|
|
||||||
- **Bend Line Detection** — Import bend lines from DXF files with pluggable detectors (SolidWorks flat pattern support built in)
|
|
||||||
- **Lead-In/Lead-Out & Tabs** — Configurable approach paths, exit paths, and holding tabs for CNC cutting, with snap-to-endpoint/midpoint placement
|
|
||||||
- **Contour & Program Editing** — Inline G-code editor with contour reordering, direction arrows, and cut direction reversal
|
|
||||||
- **G-code Output** — Post-process nested layouts to G-code via plugin post-processors
|
|
||||||
- **User-Defined Variables** — Define named variables in G-code (`diameter = 0.3`) referenced with `$name` syntax; Cincinnati post emits numbered machine variables (`#200`) so operators can adjust values at the control
|
|
||||||
- **Built-in Shapes** — 12 parametric shapes (circles, rectangles, L-shapes, T-shapes, flanges, etc.) for quick testing or simple parts
|
|
||||||
- **Interactive Editing** — Zoom, pan, select, clone, push, and manually arrange parts on the plate view
|
|
||||||
- **Pluggable Engine Architecture** — Swap between built-in nesting engines or load custom engines from plugin DLLs
|
|
||||||
|
|
||||||

|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **DXF/DWG Import** | Load part drawings from AutoCAD DXF or DWG files via ACadSharp |
|
||||||
|
| **DXF Export** | Export completed nest layouts back to DXF for downstream tools |
|
||||||
|
| **BOM Import** | Batch-import part lists with quantities from Excel spreadsheets |
|
||||||
|
| **Bend Line Detection** | Import bend lines from DXF via pluggable detectors (SolidWorks flat pattern built in) |
|
||||||
|
| **Built-in Shapes** | 12 parametric shapes (circles, rectangles, L/T/flange, etc.) for quick parts |
|
||||||
|
|
||||||
|
### Nesting
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Pluggable Engines** | Default multi-phase, Vertical Remnant, Horizontal Remnant, plus custom plugin DLLs |
|
||||||
|
| **Fill Strategies** | Linear grid, interlocking pairs, rectangle best-fit, and extents-based tiling |
|
||||||
|
| **Best-Fit Pair Nesting** | NFP-based pair evaluation finds tight interlocking orientations between parts |
|
||||||
|
| **Gravity Compaction** | Polygon-based directional push to close gaps after filling |
|
||||||
|
| **Part Rotation** | Automatic angle sweep to find better fits across allowed orientations |
|
||||||
|
| **Multi-Plate Support** | Manage multiple plates of different sizes and materials in one nest |
|
||||||
|
|
||||||
|
### Plate Operations
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Sheet Cut-Offs** | Auto-generated trim cuts with geometry-aware clearance around placed parts |
|
||||||
|
| **Drawing Splitting** | Split oversized parts with straight cuts, weld-gap tabs, or spike-groove joints |
|
||||||
|
| **Interactive Editing** | Zoom, pan, select, clone, rotate, push, and manually arrange parts |
|
||||||
|
|
||||||
|
### CNC Output
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Lead-Ins, Lead-Outs & Tabs** | Configurable approach/exit paths and holding tabs with snap placement |
|
||||||
|
| **Contour & Program Editing** | Inline G-code editor with contour reordering and cut-direction reversal |
|
||||||
|
| **User-Defined Variables** | Named G-code variables (`$name`) emitted as machine variables (`#200+`) at post time |
|
||||||
|
| **Post-Processors** | Plugin-based G-code generation; Cincinnati CL-707/800/900/940/CLX included |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -61,6 +81,15 @@ Or open `OpenNest.sln` in Visual Studio and run the `OpenNest` project.
|
|||||||
5. **Add cut-offs** — Optionally add horizontal/vertical cut-off lines to trim unused plate material
|
5. **Add cut-offs** — Optionally add horizontal/vertical cut-off lines to trim unused plate material
|
||||||
6. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
|
6. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
|
||||||
|
|
||||||
|
### CAD Converter
|
||||||
|
|
||||||
|
The CAD Converter turns DXF/DWG files into nest-ready drawings. Toggle layers, colors, and linetypes to exclude construction geometry; review detected bend lines; and preview the generated cut program with contour ordering before accepting the drawing into the nest.
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="screenshots/screenshot-cad-converter-1.png"><img src="screenshots/screenshot-cad-converter-1.png" width="420" alt="CAD Converter — layer, color, and linetype filtering"></a>
|
||||||
|
<a href="screenshots/screenshot-cad-converter-2.png"><img src="screenshots/screenshot-cad-converter-2.png" width="420" alt="CAD Converter — contour list and G-code preview"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Command-Line Interface
|
## Command-Line Interface
|
||||||
|
|
||||||
OpenNest includes a CLI for batch nesting without the GUI — useful for automation, scripting, and CI pipelines.
|
OpenNest includes a CLI for batch nesting without the GUI — useful for automation, scripting, and CI pipelines.
|
||||||
@@ -172,6 +201,8 @@ Oversized parts that don't fit on a single plate can be split into smaller piece
|
|||||||
|
|
||||||
The split system supports fit-to-plate (auto-calculates split lines) and split-by-count modes, with an interactive UI for adjusting split positions and feature parameters.
|
The split system supports fit-to-plate (auto-calculates split lines) and split-by-count modes, with an interactive UI for adjusting split positions and feature parameters.
|
||||||
|
|
||||||
|
**Cutout-aware clipping.** Split lines are trimmed against interior cutouts so cut paths never travel through a hole. Lines are Liang-Barsky clipped at region boundaries and arcs/circles are iteratively split at their intersections with the region box, so a cutout that straddles a split correctly contributes material to both sides. When a cutout fully spans the region between two splits, the material breaks into physically disconnected strips — the splitter detects the connected components via endpoint connectivity, nests any remaining holes inside their outer loops by bounding-box and point-in-polygon containment, and emits one drawing per strip.
|
||||||
|
|
||||||
## Post-Processors
|
## Post-Processors
|
||||||
|
|
||||||
Post-processors convert nested layouts into machine-specific G-code. They are loaded as plugin DLLs from the `Posts/` directory at runtime.
|
Post-processors convert nested layouts into machine-specific G-code. They are loaded as plugin DLLs from the `Posts/` directory at runtime.
|
||||||
@@ -212,16 +243,11 @@ Custom post-processors implement the `IPostProcessor` interface and are auto-dis
|
|||||||
|
|
||||||
Nest files (`.nest`) are ZIP archives containing:
|
Nest files (`.nest`) are ZIP archives containing:
|
||||||
|
|
||||||
- `nest.json` — JSON metadata: nest info, plate defaults, drawings (with bend data), and plates (with parts and cut-offs)
|
- `nest.json` — JSON metadata: nest info (name, customer, units, material, thickness, assist gas, salvage rate), plate defaults, plate options (alternative sizes with cost), drawings (with bend lines, material, source path, rotation constraints), and plates (size, quadrant, grain angle, parts with manual lead-in flags, cut-offs)
|
||||||
- `programs/program-N` — G-code text for each drawing's cut program (may include variable definitions and `$name` references)
|
- `programs/program-N` — G-code text for drawing N's cut program (may include variable definitions and `$name` references)
|
||||||
- `bestfits/bestfit-N` — Cached best-fit pair evaluation results (optional)
|
- `programs/program-N-subs` — Sub-program definitions for drawing N (M98/G65-callable blocks for repeated features like holes)
|
||||||
|
- `entities/entities-N` — Original source entities for drawing N (preserved from DXF import with per-entity suppression state for round-trip editing)
|
||||||
## Roadmap
|
- `bestfits/bestfit-N` — Cached best-fit pair evaluation results for drawing N, keyed by plate size and spacing (optional)
|
||||||
|
|
||||||
- **NFP-based auto-nesting** — Simulated annealing optimizer and NFP placement exist in the engine but aren't exposed as a selectable engine yet
|
|
||||||
- **Geometry simplifier** — Replace consecutive small line segments with fitted arcs to reduce program size and improve nesting performance
|
|
||||||
- **Shape library UI** — 12 built-in parametric shapes exist in code; needs a browsable library UI for quick access
|
|
||||||
- **Additional post-processors** — Plugin interface is in place; more machine-specific post-processors planned
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Reference in New Issue
Block a user