1 Commits

Author SHA1 Message Date
aj 57cb37a46b feat(core): add CanonicalAngle helper for MBR-aligning angle 2026-04-20 08:57:03 -04:00
45 changed files with 325 additions and 2035 deletions
+28 -4
View File
@@ -41,6 +41,7 @@ static class NestConsole
} }
} }
using var log = SetUpLog(options);
var nest = LoadOrCreateNest(options); var nest = LoadOrCreateNest(options);
if (nest == null) if (nest == null)
@@ -67,6 +68,10 @@ 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);
@@ -107,6 +112,9 @@ 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;
@@ -145,14 +153,28 @@ 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) || f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToList();
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)
@@ -188,7 +210,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 CAD (.dxf/.dwg) files specified"); Console.Error.WriteLine("Error: no nest (.nest) or DXF (.dxf) files specified");
return null; return null;
} }
@@ -462,7 +484,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/.dwg drawing files"); Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf 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)");
@@ -481,6 +503,7 @@ 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/)");
@@ -499,6 +522,7 @@ 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;
+4 -14
View File
@@ -1,6 +1,5 @@
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
@@ -82,21 +81,12 @@ namespace OpenNest.Converters
var startpt = arc.StartPoint(); var startpt = arc.StartPoint();
var endpt = arc.EndPoint(); var endpt = arc.EndPoint();
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance) if (startpt != lastpt)
pgm.MoveTo(startpt); pgm.MoveTo(startpt);
lastpt = endpt; lastpt = endpt;
var sweep = System.Math.Abs(arc.SweepAngle()); pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
{
pgm.LineTo(endpt);
}
else
{
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
}
return lastpt; return lastpt;
} }
@@ -104,7 +94,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.DistanceTo(lastpt) > Tolerance.ChainTolerance) if (startpt != lastpt)
pgm.MoveTo(startpt); pgm.MoveTo(startpt);
pgm.ArcTo(startpt, circle.Center, circle.Rotation); pgm.ArcTo(startpt, circle.Center, circle.Rotation);
@@ -115,7 +105,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.DistanceTo(lastpt) > Tolerance.ChainTolerance) if (line.StartPoint != lastpt)
pgm.MoveTo(line.StartPoint); pgm.MoveTo(line.StartPoint);
var move = new LinearMove(line.EndPoint); var move = new LinearMove(line.EndPoint);
+1 -31
View File
@@ -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,29 +78,9 @@ 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; }
@@ -183,15 +163,5 @@ 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; }
} }
} }
+14 -20
View File
@@ -93,9 +93,6 @@ 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>
@@ -407,29 +404,26 @@ namespace OpenNest.Geometry
maxY = startpt.Y; maxY = startpt.Y;
} }
var sweep = SweepAngle(); var angle1 = StartAngle;
if (sweep > Tolerance.Epsilon) var angle2 = EndAngle;
{
var angle1 = StartAngle;
var angle2 = EndAngle;
if (IsReversed) // switch the angle to counter clockwise.
Generic.Swap(ref angle1, ref angle2); if (IsReversed)
Generic.Swap(ref angle1, ref angle2);
if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2)) if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2))
maxY = Center.Y + Radius; maxY = Center.Y + Radius;
if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2)) if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2))
minX = Center.X - Radius; minX = Center.X - Radius;
const double oneHalfPI = System.Math.PI * 1.5; const double oneHalfPI = System.Math.PI * 1.5;
if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2)) if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2))
minY = Center.Y - Radius; minY = Center.Y - Radius;
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2)) if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
maxX = Center.X + Radius; maxX = Center.X + Radius;
}
boundingBox.X = minX; boundingBox.X = minX;
boundingBox.Y = minY; boundingBox.Y = minY;
+2 -17
View File
@@ -1,9 +1,8 @@
using System; using OpenNest.Math;
using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
public class Box : IComparable<Box> public class Box
{ {
public static readonly Box Empty = new Box(); public static readonly Box Empty = new Box();
@@ -215,19 +214,5 @@ 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;
} }
} }
+1 -5
View File
@@ -173,11 +173,7 @@ namespace OpenNest.Geometry
if (maxDev <= tolerance) if (maxDev <= tolerance)
{ {
var arc = CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1); results.Add(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
{ {
@@ -17,38 +17,6 @@ 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,
+1 -92
View File
@@ -1,13 +1,12 @@
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, double? weldTolerance = null) public static List<Shape> GetShapes(IEnumerable<Entity> entities)
{ {
var lines = new List<Line>(); var lines = new List<Line>();
var arcs = new List<Arc>(); var arcs = new List<Arc>();
@@ -58,9 +57,6 @@ 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];
@@ -111,93 +107,6 @@ 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;
+4 -10
View File
@@ -24,9 +24,6 @@ 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;
@@ -34,7 +31,7 @@ namespace OpenNest.Engine.BestFit
{ {
if (CreateEvaluator != null) if (CreateEvaluator != null)
{ {
try { evaluator = CreateEvaluator(canonical, spacing); } try { evaluator = CreateEvaluator(drawing, spacing); }
catch { /* fall back to default evaluator */ } catch { /* fall back to default evaluator */ }
} }
@@ -45,7 +42,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(canonical, spacing, StepSize); var results = finder.FindBestFits(drawing, spacing, StepSize);
_cache.TryAdd(key, results); _cache.TryAdd(key, results);
return results; return results;
@@ -89,12 +86,9 @@ 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(canonical, spacing); } try { evaluator = CreateEvaluator(drawing, spacing); }
catch { /* fall back to default evaluator */ } catch { /* fall back to default evaluator */ }
} }
@@ -106,7 +100,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(canonical, spacing, StepSize); var baseResults = finder.FindBestFits(drawing, 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,10 +1,18 @@
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;
@@ -38,6 +46,12 @@ 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);
} }
@@ -49,17 +63,40 @@ 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++)
@@ -88,6 +125,20 @@ 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;
} }
@@ -109,5 +160,20 @@ 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");
}
}
} }
} }
-76
View File
@@ -1,76 +0,0 @@
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;
}
}
}
+19 -63
View File
@@ -47,29 +47,14 @@ namespace OpenNest
PhaseResults.Clear(); PhaseResults.Clear();
AngleResults.Clear(); AngleResults.Clear();
// Replace the item's Drawing with a canonical copy for the duration of this fill. // Fast path: for very small quantities, skip the full strategy pipeline.
// All internal methods see canonical geometry; this wrapper un-canonicalizes the final result. if (item.Quantity > 0 && item.Quantity <= 2)
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
var originalDrawing = item.Drawing;
var canonicalItem = new NestItem
{ {
Drawing = CanonicalFrame.AsCanonicalCopy(item.Drawing), var fast = TryFillSmallQuantity(item, workArea);
Quantity = item.Quantity, if (fast != null && fast.Count >= item.Quantity)
Priority = item.Priority,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
StepAngle = item.StepAngle,
};
// Fast path for qty 1-2.
if (canonicalItem.Quantity > 0 && canonicalItem.Quantity <= 2)
{
var fast = TryFillSmallQuantity(canonicalItem, workArea);
if (fast != null && fast.Count >= canonicalItem.Quantity)
{ {
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}"); Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={item.Quantity}");
WinnerPhase = NestPhase.Pairs; WinnerPhase = NestPhase.Pairs;
fast = RebindAndUnCanonicalize(fast, originalDrawing, sourceAngle);
ReportProgress(progress, new ProgressReport ReportProgress(progress, new ProgressReport
{ {
Phase = WinnerPhase, Phase = WinnerPhase,
@@ -83,30 +68,32 @@ 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 (canonicalItem.Quantity > 0) if (item.Quantity > 0)
{ {
effectiveWorkArea = ShrinkWorkArea(canonicalItem, workArea, Plate.PartSpacing); effectiveWorkArea = ShrinkWorkArea(item, workArea, Plate.PartSpacing);
if (effectiveWorkArea != workArea) if (effectiveWorkArea != workArea)
Debug.WriteLine($"[Fill] Low-qty shrink: {canonicalItem.Quantity} requested, " + Debug.WriteLine($"[Fill] Low-qty shrink: {item.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(canonicalItem, effectiveWorkArea, progress, token); var best = RunFillPipeline(item, effectiveWorkArea, progress, token);
if (canonicalItem.Quantity > 0 && best.Count < canonicalItem.Quantity && effectiveWorkArea != workArea) // Fallback: if the reduced area didn't yield enough, retry with full area.
if (item.Quantity > 0 && best.Count < item.Quantity && effectiveWorkArea != workArea)
{ {
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {canonicalItem.Quantity}, retrying full area"); Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {item.Quantity}, retrying full area");
PhaseResults.Clear(); PhaseResults.Clear();
AngleResults.Clear(); AngleResults.Clear();
best = RunFillPipeline(canonicalItem, workArea, progress, token); best = RunFillPipeline(item, workArea, progress, token);
} }
if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity) if (item.Quantity > 0 && best.Count > item.Quantity)
best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis); best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis);
best = RebindAndUnCanonicalize(best, originalDrawing, sourceAngle);
ReportProgress(progress, new ProgressReport ReportProgress(progress, new ProgressReport
{ {
@@ -121,31 +108,6 @@ 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.
@@ -177,10 +139,6 @@ 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)
@@ -194,7 +152,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(canonicalDrawing); var landscape = fit.BuildParts(drawing);
var portrait = RotatePair90(landscape); var portrait = RotatePair90(landscape);
var lFits = TryOffsetToWorkArea(landscape, workArea); var lFits = TryOffsetToWorkArea(landscape, workArea);
@@ -216,8 +174,6 @@ 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;
} }
+134 -59
View File
@@ -62,90 +62,91 @@ namespace OpenNest.Engine.Fill
} }
/// <summary> /// <summary>
/// Finds the geometry-aware copy distance between two identical parts along an axis. /// Computes the slide distance for the push algorithm, returning the
/// Uses native Line/Arc entities (inflated by half-spacing) so curves are handled /// geometry-aware copy distance along the given axis.
/// exactly without polygon sampling error.
/// </summary> /// </summary>
private double FindCopyDistance(Part partA, NestDirection direction) private double ComputeCopyDistance(double bboxDim, double slideDistance)
{ {
var bboxDim = GetDimension(partA.BoundingBox, direction);
var pushDir = GetPushDirection(direction);
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
var offset = MakeOffset(direction, startOffset);
var stationaryEntities = PartGeometry.GetOffsetPerimeterEntities(partA, HalfSpacing);
var movingEntities = PartGeometry.GetOffsetPerimeterEntities(
partA.CloneAtOffset(offset), HalfSpacing);
var slideDistance = SpatialQuery.DirectionalDistance(
movingEntities, stationaryEntities, pushDir);
if (slideDistance >= double.MaxValue || slideDistance < 0) if (slideDistance >= double.MaxValue || slideDistance < 0)
return bboxDim + PartSpacing; return bboxDim + PartSpacing;
return startOffset - slideDistance; // 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>
/// Finds the geometry-aware copy distance between two identical parts along an axis.
/// Both parts are inflated by half-spacing for symmetric spacing.
/// </summary>
private double FindCopyDistance(Part partA, NestDirection direction, PartBoundary boundary)
{
var bboxDim = GetDimension(partA.BoundingBox, direction);
var pushDir = GetPushDirection(direction);
var locationBOffset = MakeOffset(direction, bboxDim);
// Use the most efficient array-based overload to avoid all allocations.
var slideDistance = SpatialQuery.DirectionalDistance(
boundary.GetEdges(pushDir), partA.Location + locationBOffset,
boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location,
pushDir);
return ComputeCopyDistance(bboxDim, 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 pattern copies so multi-part patterns /// Checks every pair of parts across adjacent patterns so that multi-part
/// (e.g. interlocking pairs) maintain spacing between ALL parts. Uses native entity /// patterns (e.g. interlocking pairs) maintain spacing between ALL parts.
/// geometry inflated by half-spacing — same primitive the Compactor uses — so arcs /// Both sides are inflated by half-spacing for symmetric spacing.
/// are exact and no bbox clamp is needed.
/// </summary> /// </summary>
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction) private double FindPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary[] boundaries)
{ {
if (patternA.Parts.Count == 1) if (patternA.Parts.Count <= 1)
return FindCopyDistance(patternA.Parts[0], direction); return FindSinglePartPatternCopyDistance(patternA, direction, boundaries[0]);
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 parts = patternA.Parts; var maxCopyDistance = FindMaxPairDistance(
var stationaryBoxes = new Box[parts.Count]; patternA.Parts, boundaries, offset, pushDir, opposite, startOffset);
var movingBoxes = new Box[parts.Count];
var stationaryEntities = new List<Entity>[parts.Count];
var movingEntities = new List<Entity>[parts.Count];
for (var i = 0; i < parts.Count; i++) // The copy distance must be at least bboxDim + PartSpacing to prevent
{ // bounding box overlap. Cross-pair slides can underestimate when the
stationaryBoxes[i] = parts[i].BoundingBox; // circumscribed polygon boundary overshoots the true arc, creating
movingBoxes[i] = stationaryBoxes[i].Translate(offset); // spurious contacts between diagonal parts in adjacent copies.
} 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 movingBox = movingBoxes[j]; var movingEdges = boundaries[j].GetEdges(pushDir);
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(
movingEntities[j], stationaryEntities[i], pushDir); movingEdges, locationB,
boundaries[i].GetEdges(opposite), parts[i].Location,
pushDir);
if (slideDistance >= double.MaxValue || slideDistance < 0) if (slideDistance >= double.MaxValue || slideDistance < 0)
continue; continue;
@@ -160,15 +161,86 @@ 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) private List<Part> TilePattern(Pattern basePattern, NestDirection direction, PartBoundary[] boundaries)
{ {
var copyDistance = FindPatternCopyDistance(basePattern, direction); var copyDistance = FindPatternCopyDistance(basePattern, direction, boundaries);
if (copyDistance <= 0) if (copyDistance <= 0)
return new List<Part>(); return new List<Part>();
@@ -322,10 +394,11 @@ 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)); row.AddRange(TilePattern(pattern, direction, boundaries));
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))
{ {
@@ -337,7 +410,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)); row.AddRange(TilePattern(pattern, perpAxis, boundaries));
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))
{ {
@@ -354,8 +427,9 @@ 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)); gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
if (HasOverlappingParts(gridResult, out var a3, out var b3)) if (HasOverlappingParts(gridResult, out var a3, out var b3))
{ {
@@ -407,8 +481,9 @@ 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); var copyDistance = FindCopyDistance(template, direction, boundary);
if (copyDistance <= 0) if (copyDistance <= 0)
return seed; return seed;
+6 -9
View File
@@ -27,10 +27,7 @@ namespace OpenNest.Engine.ML
{ {
public static PartFeatures Extract(Drawing drawing) public static PartFeatures Extract(Drawing drawing)
{ {
// Normalize to canonical frame so features are invariant to import orientation. var entities = OpenNest.Converters.ConvertProgram.ToGeometry(drawing.Program)
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();
@@ -48,18 +45,18 @@ namespace OpenNest.Engine.ML
var features = new PartFeatures var features = new PartFeatures
{ {
Area = canonical.Area, Area = drawing.Area,
Convexity = canonical.Area / (hullArea > 0 ? hullArea : 1.0), Convexity = drawing.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 = canonical.Area / (bb.Area() > 0 ? bb.Area() : 1.0), BoundingBoxFill = drawing.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 * canonical.Area) / (perimeterLen * perimeterLen); features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen);
features.PerimeterToAreaRatio = canonical.Area > 0 ? perimeterLen / canonical.Area : 0; features.PerimeterToAreaRatio = drawing.Area > 0 ? perimeterLen / drawing.Area : 0;
return features; return features;
} }
+1 -35
View File
@@ -334,12 +334,6 @@ 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;
@@ -348,7 +342,7 @@ namespace OpenNest
if (!fit.Keep) if (!fit.Keep)
continue; continue;
var parts = fit.BuildParts(canonicalDrawing); var parts = fit.BuildParts(item.Drawing);
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;
@@ -380,10 +374,6 @@ 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;
@@ -398,30 +388,6 @@ 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
+2 -2
View File
@@ -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);
// Share the single angle formula with CanonicalAngle (no duplicate MBR compute). // Store primary angle (negated to align MBR with axes, same as RotationAnalysis).
result.PrimaryAngle = CanonicalAngle.FromMbr(mbr); result.PrimaryAngle = -mbr.Angle;
// Drawing perimeter for circularity and perimeter ratio. // Drawing perimeter for circularity and perimeter ratio.
var drawingPerimeter = polygon.Perimeter(); var drawingPerimeter = polygon.Perimeter();
+2 -7
View File
@@ -42,11 +42,6 @@ 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)
@@ -62,8 +57,8 @@ namespace OpenNest.IO.Bom
var lookupName = item.FileName; var lookupName = item.FileName;
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) // Strip .dxf extension if the BOM includes it
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)) if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
lookupName = Path.GetFileNameWithoutExtension(lookupName); lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (!folderExists) if (!folderExists)
@@ -3,7 +3,7 @@ using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace OpenNest.Math namespace OpenNest.IO.Bom
{ {
public static class Fraction public static class Fraction
{ {
-5
View File
@@ -16,11 +16,6 @@ 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>
-12
View File
@@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using ACadSharp;
using OpenNest.Bending; using OpenNest.Bending;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -39,16 +38,5 @@ 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; }
} }
} }
-54
View File
@@ -5,7 +5,6 @@ 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
{ {
@@ -26,9 +25,6 @@ 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)
{ {
@@ -41,10 +37,6 @@ 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,
@@ -52,8 +44,6 @@ 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,
}; };
} }
@@ -144,51 +134,7 @@ 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);
}
} }
} }
+5 -24
View File
@@ -27,7 +27,8 @@ namespace OpenNest.IO
/// </summary> /// </summary>
public static DxfImportResult Import(string path) public static DxfImportResult Import(string path)
{ {
var doc = ReadDocument(path); using var reader = new DxfReader(path);
var doc = reader.Read();
return new DxfImportResult return new DxfImportResult
{ {
@@ -40,7 +41,8 @@ namespace OpenNest.IO
{ {
try try
{ {
var doc = ReadDocument(path); using var reader = new DxfReader(path);
var doc = reader.Read();
return ConvertEntities(doc); return ConvertEntities(doc);
} }
catch (Exception ex) catch (Exception ex)
@@ -111,29 +113,11 @@ 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)
{ {
@@ -151,7 +135,7 @@ namespace OpenNest.IO
break; break;
case ACadSharp.Entities.Circle circle: case ACadSharp.Entities.Circle circle:
circles.Add(circle.ToOpenNest()); entities.Add(circle.ToOpenNest());
break; break;
case ACadSharp.Entities.Spline spline: case ACadSharp.Entities.Spline spline:
@@ -182,10 +166,7 @@ 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);
+3 -12
View File
@@ -181,22 +181,13 @@ 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 startParam = ellipse.StartParameter;
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 semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var semiMinor = semiMajor * ellipse.RadiusRatio; var semiMinor = semiMajor * ellipse.RadiusRatio;
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X); var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
var startParam = ellipse.StartParameter;
var endParam = ellipse.EndParameter;
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();
-3
View File
@@ -4,9 +4,6 @@
<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" />
-312
View File
@@ -1,312 +0,0 @@
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;
}
}
}
@@ -97,60 +97,3 @@ public class CanonicalAngleTests
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6); 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);
}
}
@@ -1,84 +0,0 @@
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);
}
}
@@ -1,84 +0,0 @@
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);
}
}
}
}
@@ -1,97 +0,0 @@
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,19 +1,14 @@
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()
{ {
@@ -249,101 +244,6 @@ 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)
{ {
@@ -1,72 +0,0 @@
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);
}
}
-16
View File
@@ -134,21 +134,5 @@ 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);
}
} }
} }
@@ -1,96 +0,0 @@
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);
}
}
@@ -1,245 +0,0 @@
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;
}
}
}
-46
View File
@@ -1,46 +0,0 @@
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);
}
}
+2 -4
View File
@@ -89,10 +89,8 @@ 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);
.Concat(Directory.GetFiles(dir, "*.dwg", SearchOption.AllDirectories)) Console.WriteLine($"Found {dxfFiles.Length} DXF files");
.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");
+18
View File
@@ -16,11 +16,15 @@ 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();
} }
@@ -36,6 +40,8 @@ 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;
@@ -52,6 +58,18 @@ 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 -1
View File
@@ -1,4 +1,4 @@
using OpenNest.Math; using OpenNest.IO.Bom;
using System; using System;
using System.Drawing; using System.Drawing;
using System.Text; using System.Text;
-16
View File
@@ -1,16 +0,0 @@
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; }
}
}
-78
View File
@@ -29,17 +29,12 @@ 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;
@@ -105,13 +100,6 @@ 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))
@@ -128,8 +116,6 @@ 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);
@@ -253,26 +239,11 @@ 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) =>
@@ -437,29 +408,10 @@ 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)
@@ -522,36 +474,6 @@ 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);
-2
View File
@@ -20,10 +20,8 @@ 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
+1 -2
View File
@@ -165,8 +165,7 @@ 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))
+2 -110
View File
@@ -92,18 +92,9 @@ 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
@@ -161,8 +152,6 @@ 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))
@@ -484,8 +473,7 @@ 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) || f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToArray();
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToArray();
if (dxfFiles.Length > 0) if (dxfFiles.Length > 0)
AddFiles(dxfFiles); AddFiles(dxfFiles);
} }
@@ -815,102 +803,6 @@ 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)
{ {
+2 -1
View File
@@ -329,7 +329,7 @@ namespace OpenNest.Forms
{ {
var dlg = new OpenFileDialog(); var dlg = new OpenFileDialog();
dlg.Multiselect = true; dlg.Multiselect = true;
dlg.Filter = "CAD Files (*.dxf;*.dwg)|*.dxf;*.dwg|DXF Files (*.dxf)|*.dxf|DWG Files (*.dwg)|*.dwg"; dlg.Filter = "DXF Files (*.dxf) | *.dxf";
if (dlg.ShowDialog() != DialogResult.OK) if (dlg.ShowDialog() != DialogResult.OK)
return; return;
@@ -346,6 +346,7 @@ 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()
+5 -25
View File
@@ -138,20 +138,9 @@ namespace OpenNest
break; break;
case CodeType.RapidMove: case CodeType.RapidMove:
{ cutPath.StartFigure();
var rapid = (RapidMove)code; leadPath.StartFigure();
var endpt = rapid.EndPoint; AddLine(cutPath, (RapidMove)code, mode, ref curpos);
if (mode == Mode.Incremental)
endpt += curpos;
var dx = endpt.X - curpos.X;
var dy = endpt.Y - curpos.Y;
if (dx * dx + dy * dy > 0.001 * 0.001)
{
cutPath.StartFigure();
leadPath.StartFigure();
}
curpos = endpt;
}
break; break;
case CodeType.SubProgramCall: case CodeType.SubProgramCall:
@@ -311,17 +300,8 @@ namespace OpenNest
break; break;
case CodeType.RapidMove: case CodeType.RapidMove:
{ Flush();
var rapid = (RapidMove)code; AddLine(path, (RapidMove)code, mode, ref curpos);
var endpt = rapid.EndPoint;
if (mode == Mode.Incremental)
endpt += curpos;
var dx = endpt.X - curpos.X;
var dy = endpt.Y - curpos.Y;
if (dx * dx + dy * dy > 0.001 * 0.001)
Flush();
curpos = endpt;
}
break; break;
case CodeType.SubProgramCall: case CodeType.SubProgramCall: