Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1945270fa7 | |||
| a18b5398de | |||
| 9d1a39aa8f | |||
| cc38934d10 | |||
| 4f849f1c06 | |||
| 4f2a8d29d5 | |||
| 09a5339b51 | |||
| 77ed1a1522 | |||
| 8ac3f5622c | |||
| c3494681a8 | |||
| c25b6bc23a | |||
| 1c994718fb | |||
| 9d58e6fba8 | |||
| 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,28 +145,14 @@ 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 =>
|
||||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
||||||
var dxfFiles = options.InputFiles.Where(f =>
|
var dxfFiles = options.InputFiles.Where(f =>
|
||||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToList();
|
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
// If we have a nest file, load it and optionally add DXFs.
|
// If we have a nest file, load it and optionally add DXFs.
|
||||||
if (nestFile != null)
|
if (nestFile != null)
|
||||||
@@ -210,7 +188,7 @@ static class NestConsole
|
|||||||
// DXF-only mode: create a fresh nest.
|
// DXF-only mode: create a fresh nest.
|
||||||
if (dxfFiles.Count == 0)
|
if (dxfFiles.Count == 0)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Error: no nest (.nest) or DXF (.dxf) files specified");
|
Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,7 +462,7 @@ static class NestConsole
|
|||||||
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
||||||
Console.Error.WriteLine();
|
Console.Error.WriteLine();
|
||||||
Console.Error.WriteLine("Arguments:");
|
Console.Error.WriteLine("Arguments:");
|
||||||
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf drawing files");
|
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files");
|
||||||
Console.Error.WriteLine();
|
Console.Error.WriteLine();
|
||||||
Console.Error.WriteLine("Modes:");
|
Console.Error.WriteLine("Modes:");
|
||||||
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
|
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
|
||||||
@@ -503,7 +481,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 +499,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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsFullCircle() =>
|
||||||
|
SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Angle in radians between start and end angles.
|
/// Angle in radians between start and end angles.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -267,6 +270,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 +407,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 +429,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>();
|
||||||
|
|||||||
@@ -17,6 +17,38 @@ namespace OpenNest.Geometry
|
|||||||
(list, item, i) => list.GetCollinearLines(item, i),
|
(list, item, i) => list.GetCollinearLines(item, i),
|
||||||
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined));
|
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined));
|
||||||
|
|
||||||
|
public static void Deduplicate(IList<Circle> circles)
|
||||||
|
{
|
||||||
|
for (var i = circles.Count - 1; i >= 1; i--)
|
||||||
|
{
|
||||||
|
for (var j = i - 1; j >= 0; j--)
|
||||||
|
{
|
||||||
|
if (circles[i].Center.DistanceTo(circles[j].Center) <= Tolerance.Epsilon
|
||||||
|
&& circles[i].Radius.IsEqualTo(circles[j].Radius))
|
||||||
|
{
|
||||||
|
circles.RemoveAt(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Deduplicate(IList<Circle> circles, IList<Arc> arcs)
|
||||||
|
{
|
||||||
|
for (var i = circles.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
for (var j = arcs.Count - 1; j >= 0; j--)
|
||||||
|
{
|
||||||
|
if (arcs[j].Center.DistanceTo(circles[i].Center) <= Tolerance.Epsilon
|
||||||
|
&& arcs[j].Radius.IsEqualTo(circles[i].Radius)
|
||||||
|
&& arcs[j].IsFullCircle())
|
||||||
|
{
|
||||||
|
arcs.RemoveAt(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private delegate bool TryJoin<T>(T a, T b, out T joined);
|
private delegate bool TryJoin<T>(T a, T b, out T joined);
|
||||||
|
|
||||||
private static void MergePass<T>(IList<T> items,
|
private static void MergePass<T>(IList<T> items,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ namespace OpenNest.IO.Bom
|
|||||||
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
|
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
|
||||||
dxfFiles[nameWithoutExt] = file;
|
dxfFiles[nameWithoutExt] = file;
|
||||||
}
|
}
|
||||||
|
foreach (var file in Directory.GetFiles(dxfFolder, "*.dwg"))
|
||||||
|
{
|
||||||
|
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
|
||||||
|
dxfFiles.TryAdd(nameWithoutExt, file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Partition items into: skipped, unmatched, or matched (grouped)
|
// Partition items into: skipped, unmatched, or matched (grouped)
|
||||||
@@ -57,8 +62,8 @@ namespace OpenNest.IO.Bom
|
|||||||
|
|
||||||
var lookupName = item.FileName;
|
var lookupName = item.FileName;
|
||||||
|
|
||||||
// Strip .dxf extension if the BOM includes it
|
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||||
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
|
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase))
|
||||||
lookupName = Path.GetFileNameWithoutExtension(lookupName);
|
lookupName = Path.GetFileNameWithoutExtension(lookupName);
|
||||||
|
|
||||||
if (!folderExists)
|
if (!folderExists)
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ namespace OpenNest.IO
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool DetectBends { get; set; } = true;
|
public bool DetectBends { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, detects and identifies title block entities during import. Default true.
|
||||||
|
/// </summary>
|
||||||
|
public bool DetectTitleBlock { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Override the drawing name. Null = filename without extension.
|
/// Override the drawing name. Null = filename without extension.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using ACadSharp;
|
||||||
using OpenNest.Bending;
|
using OpenNest.Bending;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
@@ -38,5 +39,16 @@ namespace OpenNest.IO
|
|||||||
/// Default drawing name (filename without extension, unless overridden).
|
/// Default drawing name (filename without extension, unless overridden).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The raw CAD document from the source file. Available for callers
|
||||||
|
/// that need access to non-geometry entities (e.g., text annotations).
|
||||||
|
/// </summary>
|
||||||
|
public CadDocument Document { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GUIDs of entities identified as part of the title block during import.
|
||||||
|
/// </summary>
|
||||||
|
public HashSet<System.Guid> TitleBlockEntityIds { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using OpenNest.Bending;
|
|||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO.Bending;
|
using OpenNest.IO.Bending;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.IO
|
namespace OpenNest.IO
|
||||||
{
|
{
|
||||||
@@ -25,6 +26,9 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
var dxf = Dxf.Import(path);
|
var dxf = Dxf.Import(path);
|
||||||
|
|
||||||
|
RemoveDuplicateArcs(dxf.Entities);
|
||||||
|
RemoveZeroSweepArcs(dxf.Entities);
|
||||||
|
|
||||||
var bends = new List<Bend>();
|
var bends = new List<Bend>();
|
||||||
if (options.DetectBends && dxf.Document != null)
|
if (options.DetectBends && dxf.Document != null)
|
||||||
{
|
{
|
||||||
@@ -37,6 +41,10 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
Bend.UpdateEtchEntities(dxf.Entities, bends);
|
Bend.UpdateEtchEntities(dxf.Entities, bends);
|
||||||
|
|
||||||
|
HashSet<System.Guid> titleBlockIds = null;
|
||||||
|
if (options.DetectTitleBlock)
|
||||||
|
titleBlockIds = TitleBlockDetector.Detect(dxf.Entities, dxf.Document);
|
||||||
|
|
||||||
return new CadImportResult
|
return new CadImportResult
|
||||||
{
|
{
|
||||||
Entities = dxf.Entities,
|
Entities = dxf.Entities,
|
||||||
@@ -44,6 +52,8 @@ namespace OpenNest.IO
|
|||||||
Bounds = dxf.Entities.GetBoundingBox(),
|
Bounds = dxf.Entities.GetBoundingBox(),
|
||||||
SourcePath = path,
|
SourcePath = path,
|
||||||
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
|
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
|
||||||
|
Document = dxf.Document,
|
||||||
|
TitleBlockEntityIds = titleBlockIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +144,51 @@ namespace OpenNest.IO
|
|||||||
.Where(e => !(e.Layer != null && e.Layer.IsVisible && e.IsVisible))
|
.Where(e => !(e.Layer != null && e.Layer.IsVisible && e.IsVisible))
|
||||||
.Select(e => e.Id));
|
.Select(e => e.Id));
|
||||||
|
|
||||||
|
if (result.TitleBlockEntityIds != null)
|
||||||
|
{
|
||||||
|
var sourceIds = new HashSet<System.Guid>(drawing.SourceEntities.Select(e => e.Id));
|
||||||
|
foreach (var id in result.TitleBlockEntityIds)
|
||||||
|
{
|
||||||
|
if (sourceIds.Contains(id))
|
||||||
|
drawing.SuppressedEntityIds.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return drawing;
|
return drawing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static void RemoveZeroSweepArcs(List<Entity> entities)
|
||||||
|
{
|
||||||
|
entities.RemoveAll(e =>
|
||||||
|
e is Arc arc && arc.StartAngle.IsEqualTo(arc.EndAngle, Tolerance.ChainTolerance));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-5
@@ -27,8 +27,7 @@ namespace OpenNest.IO
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static DxfImportResult Import(string path)
|
public static DxfImportResult Import(string path)
|
||||||
{
|
{
|
||||||
using var reader = new DxfReader(path);
|
var doc = ReadDocument(path);
|
||||||
var doc = reader.Read();
|
|
||||||
|
|
||||||
return new DxfImportResult
|
return new DxfImportResult
|
||||||
{
|
{
|
||||||
@@ -41,8 +40,7 @@ namespace OpenNest.IO
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var reader = new DxfReader(path);
|
var doc = ReadDocument(path);
|
||||||
var doc = reader.Read();
|
|
||||||
return ConvertEntities(doc);
|
return ConvertEntities(doc);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -113,11 +111,29 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
#region Private
|
#region Private
|
||||||
|
|
||||||
|
private static bool IsDwg(string path) =>
|
||||||
|
Path.GetExtension(path).Equals(".dwg", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static CadDocument ReadDocument(string path)
|
||||||
|
{
|
||||||
|
if (IsDwg(path))
|
||||||
|
{
|
||||||
|
using var reader = new DwgReader(path);
|
||||||
|
return reader.Read();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var reader = new DxfReader(path);
|
||||||
|
return reader.Read();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static List<Entity> ConvertEntities(CadDocument doc)
|
private static List<Entity> ConvertEntities(CadDocument doc)
|
||||||
{
|
{
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
var lines = new List<Line>();
|
var lines = new List<Line>();
|
||||||
var arcs = new List<Arc>();
|
var arcs = new List<Arc>();
|
||||||
|
var circles = new List<Circle>();
|
||||||
|
|
||||||
foreach (var entity in doc.Entities)
|
foreach (var entity in doc.Entities)
|
||||||
{
|
{
|
||||||
@@ -135,7 +151,7 @@ namespace OpenNest.IO
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case ACadSharp.Entities.Circle circle:
|
case ACadSharp.Entities.Circle circle:
|
||||||
entities.Add(circle.ToOpenNest());
|
circles.Add(circle.ToOpenNest());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ACadSharp.Entities.Spline spline:
|
case ACadSharp.Entities.Spline spline:
|
||||||
@@ -166,7 +182,10 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
GeometryOptimizer.Optimize(lines);
|
GeometryOptimizer.Optimize(lines);
|
||||||
GeometryOptimizer.Optimize(arcs);
|
GeometryOptimizer.Optimize(arcs);
|
||||||
|
GeometryOptimizer.Deduplicate(circles);
|
||||||
|
GeometryOptimizer.Deduplicate(circles, arcs);
|
||||||
|
|
||||||
|
entities.AddRange(circles);
|
||||||
entities.AddRange(lines);
|
entities.AddRange(lines);
|
||||||
entities.AddRange(arcs);
|
entities.AddRange(arcs);
|
||||||
|
|
||||||
|
|||||||
@@ -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,312 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using ACadSharp;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.IO
|
||||||
|
{
|
||||||
|
public static class TitleBlockDetector
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> TitleBlockLayerNames = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"TITLE", "TITLEBLOCK", "TITLE_BLOCK", "BORDER", "FRAME",
|
||||||
|
"TB", "INFO", "SHEET", "ANNOTATION"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static HashSet<Guid> Detect(List<Entity> entities, CadDocument document)
|
||||||
|
{
|
||||||
|
var flagged = new HashSet<Guid>();
|
||||||
|
DetectByLayerName(entities, flagged);
|
||||||
|
DetectBorder(entities, flagged);
|
||||||
|
if (document != null)
|
||||||
|
DetectTitleBlockRegion(entities, document, flagged);
|
||||||
|
return flagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DetectByLayerName(List<Entity> entities, HashSet<Guid> flagged)
|
||||||
|
{
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
if (entity.Layer?.Name != null && TitleBlockLayerNames.Contains(entity.Layer.Name))
|
||||||
|
flagged.Add(entity.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DetectBorder(List<Entity> entities, HashSet<Guid> flagged)
|
||||||
|
{
|
||||||
|
var lines = entities.OfType<Line>().Where(l => !flagged.Contains(l.Id)).ToList();
|
||||||
|
if (lines.Count < 4) return;
|
||||||
|
|
||||||
|
var bounds = entities.GetBoundingBox();
|
||||||
|
if (bounds == null || bounds.Area() < OpenNest.Math.Tolerance.Epsilon) return;
|
||||||
|
|
||||||
|
var borderCount = 0;
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (IsBorderLine(line, bounds))
|
||||||
|
{
|
||||||
|
flagged.Add(line.Id);
|
||||||
|
borderCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (borderCount >= 2)
|
||||||
|
DetectZoneMarkers(lines, bounds, flagged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBorderLine(Line line, Box bounds)
|
||||||
|
{
|
||||||
|
var dx = line.EndPoint.X - line.StartPoint.X;
|
||||||
|
var dy = line.EndPoint.Y - line.StartPoint.Y;
|
||||||
|
var length = System.Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
var angleRad = System.Math.Atan2(System.Math.Abs(dy), System.Math.Abs(dx));
|
||||||
|
var angularTolerance = OpenNest.Math.Angle.ToRadians(2.0);
|
||||||
|
var positionTolerance = System.Math.Max(bounds.Length, bounds.Width) * 0.01;
|
||||||
|
|
||||||
|
var isHorizontal = angleRad < angularTolerance;
|
||||||
|
var isVertical = System.Math.Abs(angleRad - System.Math.PI / 2) < angularTolerance;
|
||||||
|
|
||||||
|
if (!isHorizontal && !isVertical) return false;
|
||||||
|
|
||||||
|
var minSpan = isHorizontal ? bounds.Length * 0.8 : bounds.Width * 0.8;
|
||||||
|
if (length < minSpan) return false;
|
||||||
|
|
||||||
|
if (isHorizontal)
|
||||||
|
{
|
||||||
|
var midY = (line.StartPoint.Y + line.EndPoint.Y) / 2;
|
||||||
|
return System.Math.Abs(midY - bounds.Bottom) < positionTolerance
|
||||||
|
|| System.Math.Abs(midY - bounds.Top) < positionTolerance;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var midX = (line.StartPoint.X + line.EndPoint.X) / 2;
|
||||||
|
return System.Math.Abs(midX - bounds.Left) < positionTolerance
|
||||||
|
|| System.Math.Abs(midX - bounds.Right) < positionTolerance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DetectZoneMarkers(List<Line> lines, Box bounds, HashSet<Guid> flagged)
|
||||||
|
{
|
||||||
|
var positionTolerance = System.Math.Max(bounds.Length, bounds.Width) * 0.01;
|
||||||
|
var maxTickLength = System.Math.Max(bounds.Length, bounds.Width) * 0.05;
|
||||||
|
var angularTolerance = OpenNest.Math.Angle.ToRadians(2.0);
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (flagged.Contains(line.Id)) continue;
|
||||||
|
|
||||||
|
var dx = line.EndPoint.X - line.StartPoint.X;
|
||||||
|
var dy = line.EndPoint.Y - line.StartPoint.Y;
|
||||||
|
var length = System.Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (length > maxTickLength || length < OpenNest.Math.Tolerance.Epsilon) continue;
|
||||||
|
|
||||||
|
var angleRad = System.Math.Atan2(System.Math.Abs(dy), System.Math.Abs(dx));
|
||||||
|
var isVertical = System.Math.Abs(angleRad - System.Math.PI / 2) < angularTolerance;
|
||||||
|
var isHorizontal = angleRad < angularTolerance;
|
||||||
|
|
||||||
|
if (!isVertical && !isHorizontal) continue;
|
||||||
|
|
||||||
|
var touchesEdge = false;
|
||||||
|
if (isVertical)
|
||||||
|
{
|
||||||
|
var minY = System.Math.Min(line.StartPoint.Y, line.EndPoint.Y);
|
||||||
|
var maxY = System.Math.Max(line.StartPoint.Y, line.EndPoint.Y);
|
||||||
|
touchesEdge = System.Math.Abs(minY - bounds.Bottom) < positionTolerance
|
||||||
|
|| System.Math.Abs(maxY - bounds.Top) < positionTolerance;
|
||||||
|
}
|
||||||
|
else if (isHorizontal)
|
||||||
|
{
|
||||||
|
var minX = System.Math.Min(line.StartPoint.X, line.EndPoint.X);
|
||||||
|
var maxX = System.Math.Max(line.StartPoint.X, line.EndPoint.X);
|
||||||
|
touchesEdge = System.Math.Abs(minX - bounds.Left) < positionTolerance
|
||||||
|
|| System.Math.Abs(maxX - bounds.Right) < positionTolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touchesEdge)
|
||||||
|
flagged.Add(line.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DetectTitleBlockRegion(List<Entity> entities, CadDocument document, HashSet<Guid> flagged)
|
||||||
|
{
|
||||||
|
var textPositions = ExtractTextPositions(document);
|
||||||
|
if (textPositions.Count < 3) return;
|
||||||
|
|
||||||
|
var unflagged = entities.Where(e => !flagged.Contains(e.Id)).ToList();
|
||||||
|
if (unflagged.Count == 0) return;
|
||||||
|
|
||||||
|
var bounds = entities.GetBoundingBox();
|
||||||
|
if (bounds == null || bounds.Area() < OpenNest.Math.Tolerance.Epsilon) return;
|
||||||
|
|
||||||
|
var bestRegion = FindBestTitleBlockRegion(bounds, textPositions, unflagged);
|
||||||
|
if (bestRegion == null) return;
|
||||||
|
|
||||||
|
var initiallyInside = unflagged.Where(e => {
|
||||||
|
var c = EntityCenter(e);
|
||||||
|
return c.HasValue && RegionContains(bestRegion, c.Value);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var expandedBounds = initiallyInside.Count > 0 ? initiallyInside.GetBoundingBox() : null;
|
||||||
|
|
||||||
|
foreach (var entity in unflagged)
|
||||||
|
{
|
||||||
|
var center = EntityCenter(entity);
|
||||||
|
if (!center.HasValue) continue;
|
||||||
|
if (RegionContains(bestRegion, center.Value)
|
||||||
|
|| (expandedBounds != null && RegionContains(expandedBounds, center.Value)))
|
||||||
|
flagged.Add(entity.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Vector> ExtractTextPositions(CadDocument document)
|
||||||
|
{
|
||||||
|
var positions = new List<Vector>();
|
||||||
|
foreach (var entity in document.Entities)
|
||||||
|
{
|
||||||
|
switch (entity)
|
||||||
|
{
|
||||||
|
case ACadSharp.Entities.MText mtext:
|
||||||
|
positions.Add(new Vector(mtext.InsertPoint.X, mtext.InsertPoint.Y));
|
||||||
|
break;
|
||||||
|
case ACadSharp.Entities.TextEntity text:
|
||||||
|
var pt = text.HorizontalAlignment != 0 || text.VerticalAlignment != 0
|
||||||
|
? text.AlignmentPoint : text.InsertPoint;
|
||||||
|
positions.Add(new Vector(pt.X, pt.Y));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Box FindBestTitleBlockRegion(Box bounds, List<Vector> textPositions, List<Entity> entities)
|
||||||
|
{
|
||||||
|
var candidates = GenerateCandidateRegions(bounds);
|
||||||
|
Box bestRegion = null;
|
||||||
|
var bestScore = 0.0;
|
||||||
|
|
||||||
|
var openLines = FindOpenLines(entities);
|
||||||
|
|
||||||
|
foreach (var region in candidates)
|
||||||
|
{
|
||||||
|
var textCount = textPositions.Count(p => RegionContains(region, p));
|
||||||
|
if (textCount < 3) continue;
|
||||||
|
|
||||||
|
var openLineCount = openLines.Count(l => RegionContains(region, l.MidPoint));
|
||||||
|
|
||||||
|
var area = region.Area();
|
||||||
|
if (area < OpenNest.Math.Tolerance.Epsilon) continue;
|
||||||
|
|
||||||
|
var score = (double)textCount + openLineCount * 0.5;
|
||||||
|
|
||||||
|
var regionCenterX = (region.Left + region.Right) / 2;
|
||||||
|
var regionCenterY = (region.Bottom + region.Top) / 2;
|
||||||
|
if (regionCenterX > bounds.Center.X) score *= 1.3;
|
||||||
|
if (regionCenterY < bounds.Center.Y) score *= 1.3;
|
||||||
|
|
||||||
|
if (score > bestScore)
|
||||||
|
{
|
||||||
|
bestScore = score;
|
||||||
|
bestRegion = region;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Box> GenerateCandidateRegions(Box bounds)
|
||||||
|
{
|
||||||
|
var regions = new List<Box>();
|
||||||
|
var fractions = new[] { 0.25, 0.333, 0.5 };
|
||||||
|
|
||||||
|
foreach (var fx in fractions)
|
||||||
|
{
|
||||||
|
foreach (var fy in fractions)
|
||||||
|
{
|
||||||
|
var w = bounds.Length * fx;
|
||||||
|
var h = bounds.Width * fy;
|
||||||
|
|
||||||
|
regions.Add(new Box(bounds.Right - w, bounds.Bottom, w, h));
|
||||||
|
regions.Add(new Box(bounds.Left, bounds.Bottom, w, h));
|
||||||
|
regions.Add(new Box(bounds.Right - w, bounds.Top - h, w, h));
|
||||||
|
regions.Add(new Box(bounds.Left, bounds.Top - h, w, h));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var fy in fractions)
|
||||||
|
{
|
||||||
|
var h = bounds.Width * fy;
|
||||||
|
regions.Add(new Box(bounds.Left, bounds.Bottom, bounds.Length, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var fx in fractions)
|
||||||
|
{
|
||||||
|
var w = bounds.Length * fx;
|
||||||
|
regions.Add(new Box(bounds.Right - w, bounds.Bottom, w, bounds.Width));
|
||||||
|
}
|
||||||
|
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Line> FindOpenLines(List<Entity> entities)
|
||||||
|
{
|
||||||
|
var endpointUsers = new Dictionary<long, int>();
|
||||||
|
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
foreach (var ep in GetEntityEndpoints(entity))
|
||||||
|
{
|
||||||
|
var key = QuantizePoint(ep);
|
||||||
|
endpointUsers[key] = endpointUsers.GetValueOrDefault(key) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var openLines = new List<Line>();
|
||||||
|
foreach (var line in entities.OfType<Line>())
|
||||||
|
{
|
||||||
|
var startKey = QuantizePoint(line.StartPoint);
|
||||||
|
var endKey = QuantizePoint(line.EndPoint);
|
||||||
|
|
||||||
|
if (endpointUsers.GetValueOrDefault(startKey) <= 1 || endpointUsers.GetValueOrDefault(endKey) <= 1)
|
||||||
|
openLines.Add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return openLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Vector> GetEntityEndpoints(Entity entity)
|
||||||
|
{
|
||||||
|
return entity switch
|
||||||
|
{
|
||||||
|
Line line => new List<Vector> { line.StartPoint, line.EndPoint },
|
||||||
|
Arc arc => new List<Vector> { arc.StartPoint(), arc.EndPoint() },
|
||||||
|
_ => new List<Vector>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long QuantizePoint(Vector pt)
|
||||||
|
{
|
||||||
|
var qx = (long)(pt.X * 1000);
|
||||||
|
var qy = (long)(pt.Y * 1000);
|
||||||
|
return qx * 100000000L + qy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector? EntityCenter(Entity entity)
|
||||||
|
{
|
||||||
|
return entity switch
|
||||||
|
{
|
||||||
|
Line line => line.MidPoint,
|
||||||
|
Arc arc => arc.Center,
|
||||||
|
Circle circle => circle.Center,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RegionContains(Box box, Vector pt)
|
||||||
|
{
|
||||||
|
return pt.X >= box.Left && pt.X <= box.Right
|
||||||
|
&& pt.Y >= box.Bottom && pt.Y <= box.Top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,5 +134,21 @@ namespace OpenNest.Tests.IO
|
|||||||
Assert.NotNull(drawing.Program);
|
Assert.NotNull(drawing.Program);
|
||||||
Assert.NotNull(drawing.SourceEntities);
|
Assert.NotNull(drawing.SourceEntities);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Import_WhenDetectTitleBlockTrue_PopulatesTitleBlockEntityIds()
|
||||||
|
{
|
||||||
|
var result = CadImporter.Import(TestDxf);
|
||||||
|
|
||||||
|
Assert.NotNull(result.TitleBlockEntityIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Import_WhenDetectTitleBlockFalse_TitleBlockEntityIdsIsNull()
|
||||||
|
{
|
||||||
|
var result = CadImporter.Import(TestDxf, new CadImportOptions { DetectTitleBlock = false });
|
||||||
|
|
||||||
|
Assert.Null(result.TitleBlockEntityIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,245 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using CSMath;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.IO
|
||||||
|
{
|
||||||
|
public class TitleBlockDetectorTests
|
||||||
|
{
|
||||||
|
private static Line MakeLine(double x1, double y1, double x2, double y2) =>
|
||||||
|
new Line(x1, y1, x2, y2);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectByLayerName_FlagsTitleLayer()
|
||||||
|
{
|
||||||
|
var line = MakeLine(0, 0, 10, 0);
|
||||||
|
line.Layer = new Layer("TITLE");
|
||||||
|
var entities = new List<Entity> { line };
|
||||||
|
|
||||||
|
var result = TitleBlockDetector.Detect(entities, null);
|
||||||
|
|
||||||
|
Assert.Contains(line.Id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectByLayerName_CaseInsensitive()
|
||||||
|
{
|
||||||
|
var line = MakeLine(0, 0, 10, 0);
|
||||||
|
line.Layer = new Layer("border");
|
||||||
|
var entities = new List<Entity> { line };
|
||||||
|
|
||||||
|
var result = TitleBlockDetector.Detect(entities, null);
|
||||||
|
|
||||||
|
Assert.Contains(line.Id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectByLayerName_IgnoresNonMatchingLayers()
|
||||||
|
{
|
||||||
|
var line = MakeLine(0, 0, 10, 0);
|
||||||
|
line.Layer = new Layer("0");
|
||||||
|
var entities = new List<Entity> { line };
|
||||||
|
|
||||||
|
var result = TitleBlockDetector.Detect(entities, null);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(line.Id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("TITLE")]
|
||||||
|
[InlineData("TITLEBLOCK")]
|
||||||
|
[InlineData("TITLE_BLOCK")]
|
||||||
|
[InlineData("BORDER")]
|
||||||
|
[InlineData("FRAME")]
|
||||||
|
[InlineData("TB")]
|
||||||
|
[InlineData("INFO")]
|
||||||
|
[InlineData("SHEET")]
|
||||||
|
[InlineData("ANNOTATION")]
|
||||||
|
public void DetectByLayerName_AllKnownNames(string layerName)
|
||||||
|
{
|
||||||
|
var line = MakeLine(0, 0, 10, 0);
|
||||||
|
line.Layer = new Layer(layerName);
|
||||||
|
var entities = new List<Entity> { line };
|
||||||
|
|
||||||
|
var result = TitleBlockDetector.Detect(entities, null);
|
||||||
|
|
||||||
|
Assert.Contains(line.Id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectBorder_FlagsLinesOnBoundingBoxEdges()
|
||||||
|
{
|
||||||
|
var entities = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(0, 0, 86, 0) { Layer = new Layer("0") },
|
||||||
|
new Line(86, 0, 86, 134) { Layer = new Layer("0") },
|
||||||
|
new Line(86, 134, 0, 134) { Layer = new Layer("0") },
|
||||||
|
new Line(0, 134, 0, 0) { Layer = new Layer("0") },
|
||||||
|
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
|
||||||
|
new Line(50, 90, 70, 40) { Layer = new Layer("0") },
|
||||||
|
new Line(70, 40, 30, 40) { Layer = new Layer("0") },
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = TitleBlockDetector.Detect(entities, null);
|
||||||
|
|
||||||
|
Assert.Contains(entities[0].Id, result);
|
||||||
|
Assert.Contains(entities[1].Id, result);
|
||||||
|
Assert.Contains(entities[2].Id, result);
|
||||||
|
Assert.Contains(entities[3].Id, result);
|
||||||
|
Assert.DoesNotContain(entities[4].Id, result);
|
||||||
|
Assert.DoesNotContain(entities[5].Id, result);
|
||||||
|
Assert.DoesNotContain(entities[6].Id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectBorder_FlagsZoneMarkerTicks()
|
||||||
|
{
|
||||||
|
var entities = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(0, 0, 100, 0) { Layer = new Layer("0") },
|
||||||
|
new Line(100, 0, 100, 80) { Layer = new Layer("0") },
|
||||||
|
new Line(100, 80, 0, 80) { Layer = new Layer("0") },
|
||||||
|
new Line(0, 80, 0, 0) { Layer = new Layer("0") },
|
||||||
|
new Line(25, 80, 25, 77) { Layer = new Layer("0") },
|
||||||
|
new Line(50, 80, 50, 77) { Layer = new Layer("0") },
|
||||||
|
new Line(75, 80, 75, 77) { Layer = new Layer("0") },
|
||||||
|
new Line(40, 30, 60, 30) { Layer = new Layer("0") },
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = TitleBlockDetector.Detect(entities, null);
|
||||||
|
|
||||||
|
Assert.Contains(entities[4].Id, result);
|
||||||
|
Assert.Contains(entities[5].Id, result);
|
||||||
|
Assert.Contains(entities[6].Id, result);
|
||||||
|
Assert.DoesNotContain(entities[7].Id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectBorder_IgnoresWhenNoBorderPresent()
|
||||||
|
{
|
||||||
|
var entities = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
|
||||||
|
new Line(50, 90, 70, 40) { Layer = new Layer("0") },
|
||||||
|
new Line(70, 40, 30, 40) { Layer = new Layer("0") },
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = TitleBlockDetector.Detect(entities, null);
|
||||||
|
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectBorder_ToleratesSlightRotation()
|
||||||
|
{
|
||||||
|
var angleRad = OpenNest.Math.Angle.ToRadians(0.5);
|
||||||
|
var endY = 86 * System.Math.Sin(angleRad);
|
||||||
|
var entities = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(0, 0, 86, endY) { Layer = new Layer("0") },
|
||||||
|
new Line(86, endY, 86, 134) { Layer = new Layer("0") },
|
||||||
|
new Line(86, 134, 0, 134) { Layer = new Layer("0") },
|
||||||
|
new Line(0, 134, 0, 0) { Layer = new Layer("0") },
|
||||||
|
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = TitleBlockDetector.Detect(entities, null);
|
||||||
|
|
||||||
|
Assert.Contains(entities[0].Id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectTitleBlock_FlagsEntitiesInTextDenseCorner()
|
||||||
|
{
|
||||||
|
var partLine1 = new Line(5, 70, 25, 120) { Layer = new Layer("0") };
|
||||||
|
var partLine2 = new Line(25, 120, 45, 70) { Layer = new Layer("0") };
|
||||||
|
var partLine3 = new Line(45, 70, 5, 70) { Layer = new Layer("0") };
|
||||||
|
|
||||||
|
var tbLines = new List<Entity>();
|
||||||
|
for (var x = 50; x <= 85; x += 5)
|
||||||
|
tbLines.Add(new Line(x, 0, x, 30) { Layer = new Layer("0") });
|
||||||
|
for (var y = 0; y <= 30; y += 5)
|
||||||
|
tbLines.Add(new Line(50, y, 85, y) { Layer = new Layer("0") });
|
||||||
|
|
||||||
|
var entities = new List<Entity> { partLine1, partLine2, partLine3 };
|
||||||
|
entities.AddRange(tbLines);
|
||||||
|
|
||||||
|
var doc = BuildDocWithTexts(
|
||||||
|
(60, 5, "TITLE: Test Part"),
|
||||||
|
(60, 10, "DWG NO: 12345"),
|
||||||
|
(60, 15, "SCALE: 1:1"),
|
||||||
|
(60, 20, "REV: A"),
|
||||||
|
(60, 25, "MATERIAL: STEEL"));
|
||||||
|
|
||||||
|
var result = TitleBlockDetector.Detect(entities, doc);
|
||||||
|
|
||||||
|
foreach (var tb in tbLines)
|
||||||
|
Assert.Contains(tb.Id, result);
|
||||||
|
Assert.DoesNotContain(partLine1.Id, result);
|
||||||
|
Assert.DoesNotContain(partLine2.Id, result);
|
||||||
|
Assert.DoesNotContain(partLine3.Id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectTitleBlock_NoFalsePositivesWithoutText()
|
||||||
|
{
|
||||||
|
var entities = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
|
||||||
|
new Line(50, 90, 70, 40) { Layer = new Layer("0") },
|
||||||
|
new Line(70, 40, 30, 40) { Layer = new Layer("0") },
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = TitleBlockDetector.Detect(entities, null);
|
||||||
|
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectTitleBlock_BottomEdgeStrip()
|
||||||
|
{
|
||||||
|
var partLine = new Line(20, 40, 80, 40) { Layer = new Layer("0") };
|
||||||
|
|
||||||
|
var tbLines = new List<Entity>();
|
||||||
|
for (var x = 0; x <= 100; x += 10)
|
||||||
|
tbLines.Add(new Line(x, 0, x, 20) { Layer = new Layer("0") });
|
||||||
|
for (var y = 0; y <= 20; y += 5)
|
||||||
|
tbLines.Add(new Line(0, y, 100, y) { Layer = new Layer("0") });
|
||||||
|
|
||||||
|
var entities = new List<Entity> { partLine };
|
||||||
|
entities.AddRange(tbLines);
|
||||||
|
|
||||||
|
var doc = BuildDocWithTexts(
|
||||||
|
(10, 5, "TITLE"),
|
||||||
|
(30, 5, "DWG NO"),
|
||||||
|
(50, 5, "SCALE"),
|
||||||
|
(70, 5, "REV"),
|
||||||
|
(90, 5, "DATE"));
|
||||||
|
|
||||||
|
var result = TitleBlockDetector.Detect(entities, doc);
|
||||||
|
|
||||||
|
foreach (var tb in tbLines)
|
||||||
|
Assert.Contains(tb.Id, result);
|
||||||
|
Assert.DoesNotContain(partLine.Id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ACadSharp.CadDocument BuildDocWithTexts(
|
||||||
|
params (double x, double y, string value)[] texts)
|
||||||
|
{
|
||||||
|
var doc = new ACadSharp.CadDocument();
|
||||||
|
foreach (var (x, y, value) in texts)
|
||||||
|
{
|
||||||
|
var mtext = new ACadSharp.Entities.MText
|
||||||
|
{
|
||||||
|
InsertPoint = new XYZ(x, y, 0),
|
||||||
|
Value = value,
|
||||||
|
Height = 2.0
|
||||||
|
};
|
||||||
|
doc.Entities.Add(mtext);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -89,8 +89,10 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
|
|||||||
new Size(48, 24), new Size(120, 10)
|
new Size(48, 24), new Size(120, 10)
|
||||||
};
|
};
|
||||||
|
|
||||||
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories);
|
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories)
|
||||||
Console.WriteLine($"Found {dxfFiles.Length} DXF files");
|
.Concat(Directory.GetFiles(dir, "*.dwg", SearchOption.AllDirectories))
|
||||||
|
.ToArray();
|
||||||
|
Console.WriteLine($"Found {dxfFiles.Length} CAD files");
|
||||||
var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db";
|
var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db";
|
||||||
Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}");
|
Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}");
|
||||||
Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations");
|
Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations");
|
||||||
|
|||||||
@@ -16,15 +16,11 @@ namespace OpenNest.Actions
|
|||||||
private CutOffSettings settings;
|
private CutOffSettings settings;
|
||||||
private CutOffAxis lockedAxis = CutOffAxis.Vertical;
|
private CutOffAxis lockedAxis = CutOffAxis.Vertical;
|
||||||
private Dictionary<Part, Entity> perimeterCache;
|
private Dictionary<Part, Entity> perimeterCache;
|
||||||
private readonly Timer debounceTimer;
|
|
||||||
private bool regeneratePending;
|
|
||||||
|
|
||||||
public ActionCutOff(PlateView plateView)
|
public ActionCutOff(PlateView plateView)
|
||||||
: base(plateView)
|
: base(plateView)
|
||||||
{
|
{
|
||||||
settings = plateView.CutOffSettings;
|
settings = plateView.CutOffSettings;
|
||||||
debounceTimer = new Timer { Interval = 16 };
|
|
||||||
debounceTimer.Tick += OnDebounce;
|
|
||||||
ConnectEvents();
|
ConnectEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +36,6 @@ namespace OpenNest.Actions
|
|||||||
|
|
||||||
public override void DisconnectEvents()
|
public override void DisconnectEvents()
|
||||||
{
|
{
|
||||||
debounceTimer.Stop();
|
|
||||||
debounceTimer.Dispose();
|
|
||||||
plateView.MouseMove -= OnMouseMove;
|
plateView.MouseMove -= OnMouseMove;
|
||||||
plateView.MouseDown -= OnMouseDown;
|
plateView.MouseDown -= OnMouseDown;
|
||||||
plateView.KeyDown -= OnKeyDown;
|
plateView.KeyDown -= OnKeyDown;
|
||||||
@@ -58,18 +52,6 @@ namespace OpenNest.Actions
|
|||||||
|
|
||||||
private void OnMouseMove(object sender, MouseEventArgs e)
|
private void OnMouseMove(object sender, MouseEventArgs e)
|
||||||
{
|
{
|
||||||
regeneratePending = true;
|
|
||||||
debounceTimer.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDebounce(object sender, System.EventArgs e)
|
|
||||||
{
|
|
||||||
debounceTimer.Stop();
|
|
||||||
|
|
||||||
if (!regeneratePending)
|
|
||||||
return;
|
|
||||||
|
|
||||||
regeneratePending = false;
|
|
||||||
var pt = plateView.CurrentPoint;
|
var pt = plateView.CurrentPoint;
|
||||||
previewCutOff = new CutOff(pt, lockedAxis);
|
previewCutOff = new CutOff(pt, lockedAxis);
|
||||||
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
|
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Controls
|
||||||
|
{
|
||||||
|
public class CadText
|
||||||
|
{
|
||||||
|
public Vector Position { get; set; }
|
||||||
|
public string Value { get; set; }
|
||||||
|
public double Height { get; set; }
|
||||||
|
public double Rotation { get; set; }
|
||||||
|
public string LayerName { get; set; }
|
||||||
|
public StringAlignment HAlign { get; set; }
|
||||||
|
public StringAlignment VAlign { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -29,12 +29,17 @@ namespace OpenNest.Controls
|
|||||||
public List<Entity> SimplifierToleranceRight { get; set; }
|
public List<Entity> SimplifierToleranceRight { get; set; }
|
||||||
public List<Entity> OriginalEntities { get; set; }
|
public List<Entity> OriginalEntities { get; set; }
|
||||||
public bool ShowEntityLabels { get; set; }
|
public bool ShowEntityLabels { get; set; }
|
||||||
|
public List<CadText> Texts { get; set; } = new List<CadText>();
|
||||||
|
public HashSet<Guid> TitleBlockEntityIds { get; set; }
|
||||||
|
|
||||||
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
|
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
|
||||||
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
|
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
|
||||||
|
private readonly Dictionary<int, Pen> ghostPenCache = new Dictionary<int, Pen>();
|
||||||
private readonly Font labelFont = new Font("Segoe UI", 7f);
|
private readonly Font labelFont = new Font("Segoe UI", 7f);
|
||||||
private readonly SolidBrush labelBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
|
private readonly SolidBrush labelBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
|
||||||
private readonly SolidBrush labelBackBrush = new SolidBrush(Color.FromArgb(33, 40, 48));
|
private readonly SolidBrush labelBackBrush = new SolidBrush(Color.FromArgb(33, 40, 48));
|
||||||
|
private readonly SolidBrush textBrush = new SolidBrush(Color.FromArgb(180, 200, 200, 200));
|
||||||
|
private readonly SolidBrush ghostTextBrush = new SolidBrush(Color.FromArgb(50, 200, 200, 200));
|
||||||
|
|
||||||
public event EventHandler<Line> LinePicked;
|
public event EventHandler<Line> LinePicked;
|
||||||
public event EventHandler PickCancelled;
|
public event EventHandler PickCancelled;
|
||||||
@@ -100,6 +105,13 @@ namespace OpenNest.Controls
|
|||||||
foreach (var entity in Entities)
|
foreach (var entity in Entities)
|
||||||
{
|
{
|
||||||
if (IsEtchLayer(entity.Layer)) continue;
|
if (IsEtchLayer(entity.Layer)) continue;
|
||||||
|
|
||||||
|
if (TitleBlockEntityIds != null && TitleBlockEntityIds.Contains(entity.Id))
|
||||||
|
{
|
||||||
|
DrawGhostEntity(e.Graphics, entity);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var isHighlighted = simplifierHighlightSet != null && simplifierHighlightSet.Contains(entity);
|
var isHighlighted = simplifierHighlightSet != null && simplifierHighlightSet.Contains(entity);
|
||||||
var pen = isHighlighted
|
var pen = isHighlighted
|
||||||
? GetEntityPen(Color.FromArgb(60, entity.Color))
|
? GetEntityPen(Color.FromArgb(60, entity.Color))
|
||||||
@@ -116,6 +128,8 @@ namespace OpenNest.Controls
|
|||||||
DrawEntity(e.Graphics, entity, pen);
|
DrawEntity(e.Graphics, entity, pen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DrawTexts(e.Graphics);
|
||||||
|
|
||||||
if (ShowEntityLabels)
|
if (ShowEntityLabels)
|
||||||
DrawEntityLabels(e.Graphics);
|
DrawEntityLabels(e.Graphics);
|
||||||
|
|
||||||
@@ -239,11 +253,26 @@ namespace OpenNest.Controls
|
|||||||
return pen;
|
return pen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Pen GetGhostPen(Color color)
|
||||||
|
{
|
||||||
|
var ghostColor = Color.FromArgb(60, color.R, color.G, color.B);
|
||||||
|
var argb = ghostColor.ToArgb();
|
||||||
|
if (!ghostPenCache.TryGetValue(argb, out var pen))
|
||||||
|
{
|
||||||
|
pen = new Pen(ghostColor);
|
||||||
|
ghostPenCache[argb] = pen;
|
||||||
|
}
|
||||||
|
return pen;
|
||||||
|
}
|
||||||
|
|
||||||
public void ClearPenCache()
|
public void ClearPenCache()
|
||||||
{
|
{
|
||||||
foreach (var pen in penCache.Values)
|
foreach (var pen in penCache.Values)
|
||||||
pen.Dispose();
|
pen.Dispose();
|
||||||
penCache.Clear();
|
penCache.Clear();
|
||||||
|
foreach (var pen in ghostPenCache.Values)
|
||||||
|
pen.Dispose();
|
||||||
|
ghostPenCache.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsEtchLayer(Layer layer) =>
|
private static bool IsEtchLayer(Layer layer) =>
|
||||||
@@ -408,10 +437,29 @@ namespace OpenNest.Controls
|
|||||||
labelFont.Dispose();
|
labelFont.Dispose();
|
||||||
labelBrush.Dispose();
|
labelBrush.Dispose();
|
||||||
labelBackBrush.Dispose();
|
labelBackBrush.Dispose();
|
||||||
|
textBrush.Dispose();
|
||||||
|
ghostTextBrush.Dispose();
|
||||||
}
|
}
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrawGhostEntity(Graphics g, Entity e)
|
||||||
|
{
|
||||||
|
var pen = GetGhostPen(e.Color);
|
||||||
|
switch (e.Type)
|
||||||
|
{
|
||||||
|
case EntityType.Arc:
|
||||||
|
DrawArc(g, (Arc)e, pen);
|
||||||
|
break;
|
||||||
|
case EntityType.Circle:
|
||||||
|
DrawCircle(g, (Circle)e, pen);
|
||||||
|
break;
|
||||||
|
case EntityType.Line:
|
||||||
|
DrawLine(g, (Line)e, pen);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawEntity(Graphics g, Entity e, Pen pen)
|
private void DrawEntity(Graphics g, Entity e, Pen pen)
|
||||||
{
|
{
|
||||||
if (!e.Layer.IsVisible || !e.IsVisible)
|
if (!e.Layer.IsVisible || !e.IsVisible)
|
||||||
@@ -474,6 +522,36 @@ namespace OpenNest.Controls
|
|||||||
diameter);
|
diameter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrawTexts(Graphics g)
|
||||||
|
{
|
||||||
|
if (Texts == null || Texts.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var sf = new StringFormat();
|
||||||
|
|
||||||
|
foreach (var text in Texts)
|
||||||
|
{
|
||||||
|
var pos = PointWorldToGraph(text.Position);
|
||||||
|
var fontSize = LengthWorldToGui(text.Height);
|
||||||
|
if (fontSize < 2f) continue;
|
||||||
|
|
||||||
|
var state = g.Save();
|
||||||
|
g.TranslateTransform(pos.X, pos.Y);
|
||||||
|
|
||||||
|
if (text.Rotation != 0)
|
||||||
|
g.RotateTransform((float)OpenNest.Math.Angle.ToDegrees(text.Rotation));
|
||||||
|
|
||||||
|
sf.Alignment = text.HAlign;
|
||||||
|
sf.LineAlignment = text.VAlign;
|
||||||
|
|
||||||
|
var brush = TitleBlockEntityIds != null && TitleBlockEntityIds.Count > 0
|
||||||
|
? ghostTextBrush : textBrush;
|
||||||
|
using var font = new Font("Segoe UI", fontSize, GraphicsUnit.Pixel);
|
||||||
|
g.DrawString(text.Value, font, brush, 0, 0, sf);
|
||||||
|
g.Restore(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawPoint(Graphics g, Vector pt, Pen pen)
|
private void DrawPoint(Graphics g, Vector pt, Pen pen)
|
||||||
{
|
{
|
||||||
var pt1 = PointWorldToGraph(pt);
|
var pt1 = PointWorldToGraph(pt);
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ namespace OpenNest.Controls
|
|||||||
public List<Entity> OriginalEntities { get; set; }
|
public List<Entity> OriginalEntities { get; set; }
|
||||||
public List<Bend> Bends { get; set; } = new();
|
public List<Bend> Bends { get; set; } = new();
|
||||||
public HashSet<Guid> SuppressedEntityIds { get; set; }
|
public HashSet<Guid> SuppressedEntityIds { get; set; }
|
||||||
|
public HashSet<Guid> TitleBlockEntityIds { get; set; }
|
||||||
public Box Bounds { get; set; }
|
public Box Bounds { get; set; }
|
||||||
public int EntityCount { get; set; }
|
public int EntityCount { get; set; }
|
||||||
|
public List<CadText> Texts { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FileListControl : Control
|
public class FileListControl : Control
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ namespace OpenNest.Forms
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var lookupName = item.FileName;
|
var lookupName = item.FileName;
|
||||||
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
|
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase))
|
||||||
lookupName = Path.GetFileNameWithoutExtension(lookupName);
|
lookupName = Path.GetFileNameWithoutExtension(lookupName);
|
||||||
|
|
||||||
if (matchedPaths.TryGetValue(lookupName, out var dxfPath))
|
if (matchedPaths.TryGetValue(lookupName, out var dxfPath))
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -91,9 +92,18 @@ namespace OpenNest.Forms
|
|||||||
Customer = string.Empty,
|
Customer = string.Empty,
|
||||||
Bends = result.Bends,
|
Bends = result.Bends,
|
||||||
Bounds = result.Bounds,
|
Bounds = result.Bounds,
|
||||||
EntityCount = result.Entities.Count
|
EntityCount = result.Entities.Count,
|
||||||
|
Texts = ExtractTexts(result.Document),
|
||||||
|
TitleBlockEntityIds = result.TitleBlockEntityIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (result.TitleBlockEntityIds != null && result.TitleBlockEntityIds.Count > 0)
|
||||||
|
{
|
||||||
|
item.SuppressedEntityIds ??= new HashSet<Guid>();
|
||||||
|
foreach (var id in result.TitleBlockEntityIds)
|
||||||
|
item.SuppressedEntityIds.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
if (InvokeRequired)
|
if (InvokeRequired)
|
||||||
BeginInvoke((Action)(() => fileList.AddItem(item)));
|
BeginInvoke((Action)(() => fileList.AddItem(item)));
|
||||||
else
|
else
|
||||||
@@ -151,6 +161,8 @@ namespace OpenNest.Forms
|
|||||||
entityView1.Entities.Clear();
|
entityView1.Entities.Clear();
|
||||||
entityView1.Entities.AddRange(item.Entities);
|
entityView1.Entities.AddRange(item.Entities);
|
||||||
entityView1.Bends = item.Bends ?? new List<Bend>();
|
entityView1.Bends = item.Bends ?? new List<Bend>();
|
||||||
|
entityView1.Texts = item.Texts ?? new List<CadText>();
|
||||||
|
entityView1.TitleBlockEntityIds = item.TitleBlockEntityIds;
|
||||||
|
|
||||||
item.Entities.ForEach(e => e.IsVisible = true);
|
item.Entities.ForEach(e => e.IsVisible = true);
|
||||||
if (item.Entities.Any(e => e.Layer != null))
|
if (item.Entities.Any(e => e.Layer != null))
|
||||||
@@ -292,6 +304,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;
|
||||||
@@ -449,7 +484,8 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
var files = (string[])e.Data.GetData(DataFormats.FileDrop);
|
var files = (string[])e.Data.GetData(DataFormats.FileDrop);
|
||||||
var dxfFiles = files.Where(f =>
|
var dxfFiles = files.Where(f =>
|
||||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToArray();
|
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||||
if (dxfFiles.Length > 0)
|
if (dxfFiles.Length > 0)
|
||||||
AddFiles(dxfFiles);
|
AddFiles(dxfFiles);
|
||||||
}
|
}
|
||||||
@@ -779,6 +815,102 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
private static List<CadText> ExtractTexts(ACadSharp.CadDocument doc)
|
||||||
|
{
|
||||||
|
var texts = new List<CadText>();
|
||||||
|
if (doc == null) return texts;
|
||||||
|
|
||||||
|
foreach (var entity in doc.Entities)
|
||||||
|
{
|
||||||
|
switch (entity)
|
||||||
|
{
|
||||||
|
case ACadSharp.Entities.MText mtext:
|
||||||
|
var (mh, mv) = MapAttachmentPoint(mtext.AttachmentPoint);
|
||||||
|
texts.Add(new CadText
|
||||||
|
{
|
||||||
|
Position = new Vector(mtext.InsertPoint.X, mtext.InsertPoint.Y),
|
||||||
|
Value = ReplaceControlCodes(StripMTextFormatting(mtext.Value)),
|
||||||
|
Height = mtext.Height,
|
||||||
|
Rotation = mtext.Rotation,
|
||||||
|
LayerName = mtext.Layer?.Name,
|
||||||
|
HAlign = mh,
|
||||||
|
VAlign = mv,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ACadSharp.Entities.TextEntity text:
|
||||||
|
var useAlignment = text.HorizontalAlignment != 0
|
||||||
|
|| text.VerticalAlignment != 0;
|
||||||
|
var pt = useAlignment ? text.AlignmentPoint : text.InsertPoint;
|
||||||
|
var ha = text.HorizontalAlignment switch
|
||||||
|
{
|
||||||
|
ACadSharp.Entities.TextHorizontalAlignment.Center => System.Drawing.StringAlignment.Center,
|
||||||
|
ACadSharp.Entities.TextHorizontalAlignment.Right => System.Drawing.StringAlignment.Far,
|
||||||
|
_ => System.Drawing.StringAlignment.Near,
|
||||||
|
};
|
||||||
|
texts.Add(new CadText
|
||||||
|
{
|
||||||
|
Position = new Vector(pt.X, pt.Y),
|
||||||
|
Value = ReplaceControlCodes(text.Value),
|
||||||
|
Height = text.Height,
|
||||||
|
Rotation = text.Rotation,
|
||||||
|
LayerName = text.Layer?.Name,
|
||||||
|
HAlign = ha,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (System.Drawing.StringAlignment h, System.Drawing.StringAlignment v) MapAttachmentPoint(
|
||||||
|
ACadSharp.Entities.AttachmentPointType apt)
|
||||||
|
{
|
||||||
|
var h = apt switch
|
||||||
|
{
|
||||||
|
ACadSharp.Entities.AttachmentPointType.TopCenter
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.MiddleCenter
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.BottomCenter => System.Drawing.StringAlignment.Center,
|
||||||
|
ACadSharp.Entities.AttachmentPointType.TopRight
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.MiddleRight
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.BottomRight => System.Drawing.StringAlignment.Far,
|
||||||
|
_ => System.Drawing.StringAlignment.Near,
|
||||||
|
};
|
||||||
|
var v = apt switch
|
||||||
|
{
|
||||||
|
ACadSharp.Entities.AttachmentPointType.MiddleLeft
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.MiddleCenter
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.MiddleRight => System.Drawing.StringAlignment.Center,
|
||||||
|
ACadSharp.Entities.AttachmentPointType.BottomLeft
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.BottomCenter
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.BottomRight => System.Drawing.StringAlignment.Far,
|
||||||
|
_ => System.Drawing.StringAlignment.Near,
|
||||||
|
};
|
||||||
|
return (h, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripMTextFormatting(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return text;
|
||||||
|
var result = System.Text.RegularExpressions.Regex.Replace(text, @"\\[A-Za-z][^;]*;", "");
|
||||||
|
result = result.Replace("{", "").Replace("}", "");
|
||||||
|
return result.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReplaceControlCodes(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return text;
|
||||||
|
return text
|
||||||
|
.Replace("%%p", "±")
|
||||||
|
.Replace("%%P", "±")
|
||||||
|
.Replace("%%d", "°")
|
||||||
|
.Replace("%%D", "°")
|
||||||
|
.Replace("%%c", "⌀")
|
||||||
|
.Replace("%%C", "⌀")
|
||||||
|
.Replace("%%%", "%");
|
||||||
|
}
|
||||||
|
|
||||||
private void filterPanel_Paint(object sender, PaintEventArgs e)
|
private void filterPanel_Paint(object sender, PaintEventArgs e)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
@@ -328,7 +329,7 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
var dlg = new OpenFileDialog();
|
var dlg = new OpenFileDialog();
|
||||||
dlg.Multiselect = true;
|
dlg.Multiselect = true;
|
||||||
dlg.Filter = "DXF Files (*.dxf) | *.dxf";
|
dlg.Filter = "CAD Files (*.dxf;*.dwg)|*.dxf;*.dwg|DXF Files (*.dxf)|*.dxf|DWG Files (*.dwg)|*.dwg";
|
||||||
|
|
||||||
if (dlg.ShowDialog() != DialogResult.OK)
|
if (dlg.ShowDialog() != DialogResult.OK)
|
||||||
return;
|
return;
|
||||||
@@ -345,7 +346,6 @@ namespace OpenNest.Forms
|
|||||||
drawings.ForEach(d => Nest.Drawings.Add(d));
|
drawings.ForEach(d => Nest.Drawings.Add(d));
|
||||||
|
|
||||||
UpdateDrawingList();
|
UpdateDrawingList();
|
||||||
tabControl1.SelectedIndex = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Export()
|
public bool Export()
|
||||||
@@ -453,7 +453,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 +874,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:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user