20 Commits

Author SHA1 Message Date
aj 1945270fa7 fix(io): deduplicate circles and full-circle arcs during DXF import
Duplicate circle entities at the same location inflated pierce counts
and cut pricing (e.g. SULLYS-035 showed 9 pierces instead of 8).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:09:32 -04:00
aj a18b5398de fix(io): remove zero-sweep arcs during DXF import
DXF files can contain degenerate arcs where start angle equals end angle
(zero sweep), often left as construction artifacts by CAD software.
These create spurious shapes in ShapeBuilder — e.g. SULLYS-033.dxf
showed 5 loops instead of 4 (3 cutouts + perimeter).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 06:52:14 -04:00
aj 9d1a39aa8f feat(ui): ghost rendering for title block entities and text in EntityView
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 12:08:38 -04:00
aj cc38934d10 feat(ui): wire TitleBlockEntityIds through FileListItem and CadConverterForm
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 12:06:21 -04:00
aj 4f849f1c06 feat(io): integrate TitleBlockDetector into CadImporter pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 12:05:48 -04:00
aj 4f2a8d29d5 feat(io): add title block region detection with corner/edge scoring (phase 3)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 11:50:45 -04:00
aj 09a5339b51 feat(io): add border detection with angular tolerance and zone markers (phase 2)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 11:39:00 -04:00
aj 77ed1a1522 feat(io): add TitleBlockDetector with layer name heuristic (phase 1)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 11:36:53 -04:00
aj 8ac3f5622c feat(io): add DetectTitleBlock option and TitleBlockEntityIds result property
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 11:32:12 -04:00
aj c3494681a8 fix(ui): remove cut-off preview debounce for immediate cursor tracking
The 16ms timer delay made the preview feel laggy. Regenerate directly
on mouse move instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 06:35:48 -04:00
aj c25b6bc23a feat(ui): render DXF text annotations in CAD converter preview
Extract MText and TextEntity from the CadDocument during DXF import
and render them in the EntityView. Handles text alignment (left/center/
right via InsertPoint vs AlignmentPoint) and replaces AutoCAD control
codes (%%p → ±, %%d → °, %%c → ⌀). MText formatting codes are
stripped before display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 21:45:44 -04:00
aj 1c994718fb feat(io): add DWG file import support via ACadSharp DwgReader
ACadSharp already includes DwgReader, so this wires it up across the
entire import pipeline — Dxf.Import, CadConverter drag-drop, nest
import dialog, console CLI, BOM analyzer, and training data collector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 23:53:29 -04:00
aj 9d58e6fba8 fix(ui): stay on drawings tab after DXF import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 23:53:29 -04:00
aj 2bae5340f0 test: add nest invariance tests for fill count across import orientations
Verify that filling an L-shaped part produces consistent counts
regardless of the orientation it was imported at, and that all
placed parts stay within the plate work area.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:56 -04:00
aj 0b322817d7 fix(core): use chain tolerance for entity gap check to prevent spurious rapids
Ellipse-to-arc conversion creates tiny floating-point gaps (~0.00002")
between consecutive arc segments. ShapeBuilder chains these with
ChainTolerance (0.0001"), but ConvertGeometry checked gaps with Epsilon
(0.00001"). Gaps between these thresholds generated spurious rapid moves
that broke GraphicsPath figures, causing diagonal fill artifacts from
GDI+'s implicit figure closing.

Root cause fix: align ConvertGeometry's gap check with ShapeBuilder's
ChainTolerance so precision gaps are absorbed instead of generating rapids.

Defense-in-depth: GraphicsHelper no longer breaks figures at near-zero
rapids, protecting against any programs with residual tiny rapids.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:08 -04:00
aj e41f335c63 feat: remove duplicate arcs matching circles on same layer during DXF import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:44:54 -04:00
aj 0ab33af5d3 feat: add WeldEndpoints to ShapeBuilder for gap repair on import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 10:40:43 -04:00
aj e04c9381f3 feat: add IComparable<Box> and comparison operators to Box
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:36:23 -04:00
aj ceb9cc0b44 refactor: move Fraction from OpenNest.IO.Bom to OpenNest.Math
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:33:57 -04:00
aj 4cecaba83a fix(core): emit line instead of arc for near-zero sweep to avoid full-circle misinterpretation
Near-zero-sweep arcs with large radius (e.g. from ellipse converter) have
nearly-coincident start/end points. Downstream code (ConvertProgram, Program
BoundingBox) treats coincident start/end as a full 360° circle, inflating the
bounding box and rendering wrong geometry. Emit a LinearMove when sweep is
negligible — geometrically equivalent and avoids the ambiguity. Also fix the
ellipse converter to produce lines instead of degenerate arcs at the source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:50:38 -04:00
31 changed files with 1477 additions and 50 deletions
+4 -3
View File
@@ -151,7 +151,8 @@ static class NestConsole
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
var dxfFiles = options.InputFiles.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToList();
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList();
// If we have a nest file, load it and optionally add DXFs.
if (nestFile != null)
@@ -187,7 +188,7 @@ static class NestConsole
// DXF-only mode: create a fresh nest.
if (dxfFiles.Count == 0)
{
Console.Error.WriteLine("Error: no nest (.nest) or DXF (.dxf) files specified");
Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified");
return null;
}
@@ -461,7 +462,7 @@ static class NestConsole
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
Console.Error.WriteLine();
Console.Error.WriteLine("Arguments:");
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf drawing files");
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files");
Console.Error.WriteLine();
Console.Error.WriteLine("Modes:");
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
+13 -3
View File
@@ -1,5 +1,6 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Converters
@@ -81,12 +82,21 @@ namespace OpenNest.Converters
var startpt = arc.StartPoint();
var endpt = arc.EndPoint();
if (startpt != lastpt)
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt);
lastpt = endpt;
var sweep = System.Math.Abs(arc.SweepAngle());
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
{
pgm.LineTo(endpt);
}
else
{
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
}
return lastpt;
}
@@ -94,7 +104,7 @@ namespace OpenNest.Converters
{
var startpt = new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
if (startpt != lastpt)
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt);
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
@@ -105,7 +115,7 @@ namespace OpenNest.Converters
private static Vector AddLine(Program pgm, Vector lastpt, Line line)
{
if (line.StartPoint != lastpt)
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(line.StartPoint);
var move = new LinearMove(line.EndPoint);
+3
View File
@@ -93,6 +93,9 @@ namespace OpenNest.Geometry
}
}
public bool IsFullCircle() =>
SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
/// <summary>
/// Angle in radians between start and end angles.
/// </summary>
+17 -2
View File
@@ -1,8 +1,9 @@
using OpenNest.Math;
using System;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public class Box
public class Box : IComparable<Box>
{
public static readonly Box Empty = new Box();
@@ -214,5 +215,19 @@ namespace OpenNest.Geometry
{
return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length);
}
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;
}
}
+5 -1
View File
@@ -173,7 +173,11 @@ namespace OpenNest.Geometry
if (maxDev <= tolerance)
{
results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1));
var arc = CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1);
if (arc.SweepAngle() < Tolerance.Epsilon)
results.Add(new Line(p0, p1));
else
results.Add(arc);
}
else
{
@@ -17,6 +17,38 @@ namespace OpenNest.Geometry
(list, item, i) => list.GetCollinearLines(item, i),
(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 static void MergePass<T>(IList<T> items,
+92 -1
View File
@@ -1,12 +1,13 @@
using OpenNest.Math;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace OpenNest.Geometry
{
public static class ShapeBuilder
{
public static List<Shape> GetShapes(IEnumerable<Entity> entities)
public static List<Shape> GetShapes(IEnumerable<Entity> entities, double? weldTolerance = null)
{
var lines = new List<Line>();
var arcs = new List<Arc>();
@@ -57,6 +58,9 @@ namespace OpenNest.Geometry
entityList.AddRange(lines);
entityList.AddRange(arcs);
if (weldTolerance.HasValue)
WeldEndpoints(entityList, weldTolerance.Value);
while (entityList.Count > 0)
{
var next = entityList[0];
@@ -107,6 +111,93 @@ namespace OpenNest.Geometry
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)
{
var tol = Tolerance.ChainTolerance;
@@ -3,7 +3,7 @@ using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace OpenNest.IO.Bom
namespace OpenNest.Math
{
public static class Fraction
{
+7 -2
View File
@@ -42,6 +42,11 @@ namespace OpenNest.IO.Bom
var nameWithoutExt = Path.GetFileNameWithoutExtension(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)
@@ -57,8 +62,8 @@ namespace OpenNest.IO.Bom
var lookupName = item.FileName;
// Strip .dxf extension if the BOM includes it
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase))
lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (!folderExists)
+5
View File
@@ -16,6 +16,11 @@ namespace OpenNest.IO
/// </summary>
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>
/// Override the drawing name. Null = filename without extension.
/// </summary>
+12
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using ACadSharp;
using OpenNest.Bending;
using OpenNest.Geometry;
@@ -38,5 +39,16 @@ namespace OpenNest.IO
/// Default drawing name (filename without extension, unless overridden).
/// </summary>
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,6 +5,7 @@ using OpenNest.Bending;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO.Bending;
using OpenNest.Math;
namespace OpenNest.IO
{
@@ -25,6 +26,9 @@ namespace OpenNest.IO
var dxf = Dxf.Import(path);
RemoveDuplicateArcs(dxf.Entities);
RemoveZeroSweepArcs(dxf.Entities);
var bends = new List<Bend>();
if (options.DetectBends && dxf.Document != null)
{
@@ -37,6 +41,10 @@ namespace OpenNest.IO
Bend.UpdateEtchEntities(dxf.Entities, bends);
HashSet<System.Guid> titleBlockIds = null;
if (options.DetectTitleBlock)
titleBlockIds = TitleBlockDetector.Detect(dxf.Entities, dxf.Document);
return new CadImportResult
{
Entities = dxf.Entities,
@@ -44,6 +52,8 @@ namespace OpenNest.IO
Bounds = dxf.Entities.GetBoundingBox(),
SourcePath = path,
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
Document = dxf.Document,
TitleBlockEntityIds = titleBlockIds,
};
}
@@ -134,7 +144,51 @@ namespace OpenNest.IO
.Where(e => !(e.Layer != null && e.Layer.IsVisible && e.IsVisible))
.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;
}
internal static void RemoveZeroSweepArcs(List<Entity> entities)
{
entities.RemoveAll(e =>
e is Arc arc && arc.StartAngle.IsEqualTo(arc.EndAngle, Tolerance.ChainTolerance));
}
internal static void RemoveDuplicateArcs(List<Entity> entities)
{
var circles = entities.OfType<Circle>().ToList();
var arcs = entities.OfType<Arc>().ToList();
var arcsToRemove = new List<Arc>();
foreach (var arc in arcs)
{
foreach (var circle in circles)
{
if (arc.Layer?.Name != circle.Layer?.Name)
continue;
if (!arc.Center.DistanceTo(circle.Center).IsEqualTo(0))
continue;
if (!arc.Radius.IsEqualTo(circle.Radius))
continue;
arcsToRemove.Add(arc);
break;
}
}
foreach (var arc in arcsToRemove)
entities.Remove(arc);
}
}
}
+24 -5
View File
@@ -27,8 +27,7 @@ namespace OpenNest.IO
/// </summary>
public static DxfImportResult Import(string path)
{
using var reader = new DxfReader(path);
var doc = reader.Read();
var doc = ReadDocument(path);
return new DxfImportResult
{
@@ -41,8 +40,7 @@ namespace OpenNest.IO
{
try
{
using var reader = new DxfReader(path);
var doc = reader.Read();
var doc = ReadDocument(path);
return ConvertEntities(doc);
}
catch (Exception ex)
@@ -113,11 +111,29 @@ namespace OpenNest.IO
#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)
{
var entities = new List<Entity>();
var lines = new List<Line>();
var arcs = new List<Arc>();
var circles = new List<Circle>();
foreach (var entity in doc.Entities)
{
@@ -135,7 +151,7 @@ namespace OpenNest.IO
break;
case ACadSharp.Entities.Circle circle:
entities.Add(circle.ToOpenNest());
circles.Add(circle.ToOpenNest());
break;
case ACadSharp.Entities.Spline spline:
@@ -166,7 +182,10 @@ namespace OpenNest.IO
GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs);
GeometryOptimizer.Deduplicate(circles);
GeometryOptimizer.Deduplicate(circles, arcs);
entities.AddRange(circles);
entities.AddRange(lines);
entities.AddRange(arcs);
+312
View File
@@ -0,0 +1,312 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ACadSharp;
using OpenNest.Geometry;
namespace OpenNest.IO
{
public static class TitleBlockDetector
{
private static readonly HashSet<string> TitleBlockLayerNames = new(StringComparer.OrdinalIgnoreCase)
{
"TITLE", "TITLEBLOCK", "TITLE_BLOCK", "BORDER", "FRAME",
"TB", "INFO", "SHEET", "ANNOTATION"
};
public static HashSet<Guid> Detect(List<Entity> entities, CadDocument document)
{
var flagged = new HashSet<Guid>();
DetectByLayerName(entities, flagged);
DetectBorder(entities, flagged);
if (document != null)
DetectTitleBlockRegion(entities, document, flagged);
return flagged;
}
private static void DetectByLayerName(List<Entity> entities, HashSet<Guid> flagged)
{
foreach (var entity in entities)
{
if (entity.Layer?.Name != null && TitleBlockLayerNames.Contains(entity.Layer.Name))
flagged.Add(entity.Id);
}
}
private static void DetectBorder(List<Entity> entities, HashSet<Guid> flagged)
{
var lines = entities.OfType<Line>().Where(l => !flagged.Contains(l.Id)).ToList();
if (lines.Count < 4) return;
var bounds = entities.GetBoundingBox();
if (bounds == null || bounds.Area() < OpenNest.Math.Tolerance.Epsilon) return;
var borderCount = 0;
foreach (var line in lines)
{
if (IsBorderLine(line, bounds))
{
flagged.Add(line.Id);
borderCount++;
}
}
if (borderCount >= 2)
DetectZoneMarkers(lines, bounds, flagged);
}
private static bool IsBorderLine(Line line, Box bounds)
{
var dx = line.EndPoint.X - line.StartPoint.X;
var dy = line.EndPoint.Y - line.StartPoint.Y;
var length = System.Math.Sqrt(dx * dx + dy * dy);
var angleRad = System.Math.Atan2(System.Math.Abs(dy), System.Math.Abs(dx));
var angularTolerance = OpenNest.Math.Angle.ToRadians(2.0);
var positionTolerance = System.Math.Max(bounds.Length, bounds.Width) * 0.01;
var isHorizontal = angleRad < angularTolerance;
var isVertical = System.Math.Abs(angleRad - System.Math.PI / 2) < angularTolerance;
if (!isHorizontal && !isVertical) return false;
var minSpan = isHorizontal ? bounds.Length * 0.8 : bounds.Width * 0.8;
if (length < minSpan) return false;
if (isHorizontal)
{
var midY = (line.StartPoint.Y + line.EndPoint.Y) / 2;
return System.Math.Abs(midY - bounds.Bottom) < positionTolerance
|| System.Math.Abs(midY - bounds.Top) < positionTolerance;
}
else
{
var midX = (line.StartPoint.X + line.EndPoint.X) / 2;
return System.Math.Abs(midX - bounds.Left) < positionTolerance
|| System.Math.Abs(midX - bounds.Right) < positionTolerance;
}
}
private static void DetectZoneMarkers(List<Line> lines, Box bounds, HashSet<Guid> flagged)
{
var positionTolerance = System.Math.Max(bounds.Length, bounds.Width) * 0.01;
var maxTickLength = System.Math.Max(bounds.Length, bounds.Width) * 0.05;
var angularTolerance = OpenNest.Math.Angle.ToRadians(2.0);
foreach (var line in lines)
{
if (flagged.Contains(line.Id)) continue;
var dx = line.EndPoint.X - line.StartPoint.X;
var dy = line.EndPoint.Y - line.StartPoint.Y;
var length = System.Math.Sqrt(dx * dx + dy * dy);
if (length > maxTickLength || length < OpenNest.Math.Tolerance.Epsilon) continue;
var angleRad = System.Math.Atan2(System.Math.Abs(dy), System.Math.Abs(dx));
var isVertical = System.Math.Abs(angleRad - System.Math.PI / 2) < angularTolerance;
var isHorizontal = angleRad < angularTolerance;
if (!isVertical && !isHorizontal) continue;
var touchesEdge = false;
if (isVertical)
{
var minY = System.Math.Min(line.StartPoint.Y, line.EndPoint.Y);
var maxY = System.Math.Max(line.StartPoint.Y, line.EndPoint.Y);
touchesEdge = System.Math.Abs(minY - bounds.Bottom) < positionTolerance
|| System.Math.Abs(maxY - bounds.Top) < positionTolerance;
}
else if (isHorizontal)
{
var minX = System.Math.Min(line.StartPoint.X, line.EndPoint.X);
var maxX = System.Math.Max(line.StartPoint.X, line.EndPoint.X);
touchesEdge = System.Math.Abs(minX - bounds.Left) < positionTolerance
|| System.Math.Abs(maxX - bounds.Right) < positionTolerance;
}
if (touchesEdge)
flagged.Add(line.Id);
}
}
private static void DetectTitleBlockRegion(List<Entity> entities, CadDocument document, HashSet<Guid> flagged)
{
var textPositions = ExtractTextPositions(document);
if (textPositions.Count < 3) return;
var unflagged = entities.Where(e => !flagged.Contains(e.Id)).ToList();
if (unflagged.Count == 0) return;
var bounds = entities.GetBoundingBox();
if (bounds == null || bounds.Area() < OpenNest.Math.Tolerance.Epsilon) return;
var bestRegion = FindBestTitleBlockRegion(bounds, textPositions, unflagged);
if (bestRegion == null) return;
var initiallyInside = unflagged.Where(e => {
var c = EntityCenter(e);
return c.HasValue && RegionContains(bestRegion, c.Value);
}).ToList();
var expandedBounds = initiallyInside.Count > 0 ? initiallyInside.GetBoundingBox() : null;
foreach (var entity in unflagged)
{
var center = EntityCenter(entity);
if (!center.HasValue) continue;
if (RegionContains(bestRegion, center.Value)
|| (expandedBounds != null && RegionContains(expandedBounds, center.Value)))
flagged.Add(entity.Id);
}
}
private static List<Vector> ExtractTextPositions(CadDocument document)
{
var positions = new List<Vector>();
foreach (var entity in document.Entities)
{
switch (entity)
{
case ACadSharp.Entities.MText mtext:
positions.Add(new Vector(mtext.InsertPoint.X, mtext.InsertPoint.Y));
break;
case ACadSharp.Entities.TextEntity text:
var pt = text.HorizontalAlignment != 0 || text.VerticalAlignment != 0
? text.AlignmentPoint : text.InsertPoint;
positions.Add(new Vector(pt.X, pt.Y));
break;
}
}
return positions;
}
private static Box FindBestTitleBlockRegion(Box bounds, List<Vector> textPositions, List<Entity> entities)
{
var candidates = GenerateCandidateRegions(bounds);
Box bestRegion = null;
var bestScore = 0.0;
var openLines = FindOpenLines(entities);
foreach (var region in candidates)
{
var textCount = textPositions.Count(p => RegionContains(region, p));
if (textCount < 3) continue;
var openLineCount = openLines.Count(l => RegionContains(region, l.MidPoint));
var area = region.Area();
if (area < OpenNest.Math.Tolerance.Epsilon) continue;
var score = (double)textCount + openLineCount * 0.5;
var regionCenterX = (region.Left + region.Right) / 2;
var regionCenterY = (region.Bottom + region.Top) / 2;
if (regionCenterX > bounds.Center.X) score *= 1.3;
if (regionCenterY < bounds.Center.Y) score *= 1.3;
if (score > bestScore)
{
bestScore = score;
bestRegion = region;
}
}
return bestRegion;
}
private static List<Box> GenerateCandidateRegions(Box bounds)
{
var regions = new List<Box>();
var fractions = new[] { 0.25, 0.333, 0.5 };
foreach (var fx in fractions)
{
foreach (var fy in fractions)
{
var w = bounds.Length * fx;
var h = bounds.Width * fy;
regions.Add(new Box(bounds.Right - w, bounds.Bottom, w, h));
regions.Add(new Box(bounds.Left, bounds.Bottom, w, h));
regions.Add(new Box(bounds.Right - w, bounds.Top - h, w, h));
regions.Add(new Box(bounds.Left, bounds.Top - h, w, h));
}
}
foreach (var fy in fractions)
{
var h = bounds.Width * fy;
regions.Add(new Box(bounds.Left, bounds.Bottom, bounds.Length, h));
}
foreach (var fx in fractions)
{
var w = bounds.Length * fx;
regions.Add(new Box(bounds.Right - w, bounds.Bottom, w, bounds.Width));
}
return regions;
}
private static List<Line> FindOpenLines(List<Entity> entities)
{
var endpointUsers = new Dictionary<long, int>();
foreach (var entity in entities)
{
foreach (var ep in GetEntityEndpoints(entity))
{
var key = QuantizePoint(ep);
endpointUsers[key] = endpointUsers.GetValueOrDefault(key) + 1;
}
}
var openLines = new List<Line>();
foreach (var line in entities.OfType<Line>())
{
var startKey = QuantizePoint(line.StartPoint);
var endKey = QuantizePoint(line.EndPoint);
if (endpointUsers.GetValueOrDefault(startKey) <= 1 || endpointUsers.GetValueOrDefault(endKey) <= 1)
openLines.Add(line);
}
return openLines;
}
private static List<Vector> GetEntityEndpoints(Entity entity)
{
return entity switch
{
Line line => new List<Vector> { line.StartPoint, line.EndPoint },
Arc arc => new List<Vector> { arc.StartPoint(), arc.EndPoint() },
_ => new List<Vector>()
};
}
private static long QuantizePoint(Vector pt)
{
var qx = (long)(pt.X * 1000);
var qy = (long)(pt.Y * 1000);
return qx * 100000000L + qy;
}
private static Vector? EntityCenter(Entity entity)
{
return entity switch
{
Line line => line.MidPoint,
Arc arc => arc.Center,
Circle circle => circle.Center,
_ => null
};
}
private static bool RegionContains(Box box, Vector pt)
{
return pt.X >= box.Left && pt.X <= box.Right
&& pt.Y >= box.Bottom && pt.Y <= box.Top;
}
}
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Threading;
namespace OpenNest.Tests.Engine;
public class NestInvarianceTests
{
private static OpenNest.CNC.Program MakeLShapedProgram()
{
// L-shape: 100x50 outer rect with a 50x30 notch removed from top-right.
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return pgm;
}
private static Drawing MakeImportedAt(double rotation)
{
var pgm = MakeLShapedProgram();
if (!Tolerance.IsEqualTo(rotation, 0))
pgm.Rotate(rotation, pgm.BoundingBox().Center);
return new Drawing("L", pgm);
}
private static Plate MakePlate() => new Plate(new Size(500, 500))
{
Quadrant = 1,
PartSpacing = 2,
};
private static int RunFillCount(Drawing drawing, Plate plate)
{
BestFitCache.Clear();
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = drawing };
var parts = engine.Fill(item, plate.WorkArea(), progress: null, token: CancellationToken.None);
return parts?.Count ?? 0;
}
[Theory]
[InlineData(0.0)]
[InlineData(0.3)]
[InlineData(0.8)]
[InlineData(1.2)]
public void Fill_SameCount_AcrossImportOrientations(double theta)
{
var baseline = RunFillCount(MakeImportedAt(0.0), MakePlate());
var rotated = RunFillCount(MakeImportedAt(theta), MakePlate());
// Allow +/-1 tolerance for sweep quantization edge effects near plate boundaries.
Assert.InRange(rotated, baseline - 1, baseline + 1);
}
[Fact]
public void Fill_PlacedPartsStayWithinWorkArea_AcrossImportOrientations()
{
var plate = MakePlate();
var workArea = plate.WorkArea();
foreach (var theta in new[] { 0.0, 0.3, 0.8, 1.2 })
{
BestFitCache.Clear();
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = MakeImportedAt(theta) };
var parts = engine.Fill(item, workArea, progress: null, token: CancellationToken.None);
Assert.NotNull(parts);
foreach (var p in parts)
{
Assert.InRange(p.BoundingBox.Left, workArea.Left - 0.5, workArea.Right + 0.5);
Assert.InRange(p.BoundingBox.Bottom, workArea.Bottom - 0.5, workArea.Top + 0.5);
}
}
}
}
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Geometry;
public class BoxComparisonTests
{
[Fact]
public void GreaterThan_TallerBox_ReturnsTrue()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(tall > short_);
Assert.False(short_ > tall);
}
[Fact]
public void GreaterThan_SameWidthLongerBox_ReturnsTrue()
{
var longer = new Box(0, 0, 20, 10);
var shorter = new Box(0, 0, 10, 10);
Assert.True(longer > shorter);
Assert.False(shorter > longer);
}
[Fact]
public void LessThan_ShorterBox_ReturnsTrue()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(short_ < tall);
Assert.False(tall < short_);
}
[Fact]
public void GreaterThanOrEqual_EqualBoxes_ReturnsTrue()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.True(a >= b);
Assert.True(b >= a);
}
[Fact]
public void LessThanOrEqual_EqualBoxes_ReturnsTrue()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.True(a <= b);
Assert.True(b <= a);
}
[Fact]
public void CompareTo_TallerBox_ReturnsPositive()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(tall.CompareTo(short_) > 0);
Assert.True(short_.CompareTo(tall) < 0);
}
[Fact]
public void CompareTo_EqualBoxes_ReturnsZero()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.Equal(0, a.CompareTo(b));
}
[Fact]
public void Sort_OrdersByWidthThenLength()
{
var boxes = new List<Box>
{
new Box(0, 0, 20, 10),
new Box(0, 0, 5, 30),
new Box(0, 0, 10, 10),
};
boxes.Sort();
Assert.Equal(10, boxes[0].Width);
Assert.Equal(10, boxes[0].Length);
Assert.Equal(10, boxes[1].Width);
Assert.Equal(20, boxes[1].Length);
Assert.Equal(30, boxes[2].Width);
}
}
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.Geometry;
public class WeldEndpointsTests
{
[Fact]
public void WeldEndpoints_SnapsNearbyLineEndpoints()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.0000005, 0, 20, 0);
var entities = new List<Entity> { line1, line2 };
ShapeBuilder.WeldEndpoints(entities, 0.000001);
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) <= Tolerance.Epsilon);
}
[Fact]
public void WeldEndpoints_SnapsArcEndpointByAdjustingAngle()
{
var line = new Line(0, 0, 10, 0);
var arc = new Arc(15, 0, 5, Angle.ToRadians(180.001), Angle.ToRadians(90));
var entities = new List<Entity> { line, arc };
ShapeBuilder.WeldEndpoints(entities, 0.01);
var arcStart = arc.StartPoint();
Assert.True(line.EndPoint.DistanceTo(arcStart) <= 0.01);
}
[Fact]
public void WeldEndpoints_DoesNotWeldDistantEndpoints()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.1, 0, 20, 0);
var entities = new List<Entity> { line1, line2 };
ShapeBuilder.WeldEndpoints(entities, 0.000001);
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) > 0.01);
}
[Fact]
public void GetShapes_WithWeldTolerance_WeldsBeforeChaining()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.0000005, 0, 10.0000005, 10);
var entities = new List<Entity> { line1, line2 };
var shapes = ShapeBuilder.GetShapes(entities, weldTolerance: 0.000001);
Assert.Single(shapes);
Assert.Equal(2, shapes[0].Entities.Count);
}
[Fact]
public void GetShapes_WithoutWeldTolerance_DefaultBehavior()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10, 0, 10, 10);
var entities = new List<Entity> { line1, line2 };
var shapes = ShapeBuilder.GetShapes(entities);
Assert.Single(shapes);
Assert.Equal(2, shapes[0].Entities.Count);
}
}
+16
View File
@@ -134,5 +134,21 @@ namespace OpenNest.Tests.IO
Assert.NotNull(drawing.Program);
Assert.NotNull(drawing.SourceEntities);
}
[Fact]
public void Import_WhenDetectTitleBlockTrue_PopulatesTitleBlockEntityIds()
{
var result = CadImporter.Import(TestDxf);
Assert.NotNull(result.TitleBlockEntityIds);
}
[Fact]
public void Import_WhenDetectTitleBlockFalse_TitleBlockEntityIdsIsNull()
{
var result = CadImporter.Import(TestDxf, new CadImportOptions { DetectTitleBlock = false });
Assert.Null(result.TitleBlockEntityIds);
}
}
}
@@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.IO;
public class RemoveDuplicateArcsTests
{
[Fact]
public void RemoveDuplicateArcs_RemovesArcMatchingCircle_SameLayer()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var line = new Line(0, 0, 10, 0) { Layer = layer };
var entities = new List<Entity> { circle, arc, line };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
Assert.Contains(circle, entities);
Assert.Contains(line, entities);
Assert.DoesNotContain(arc, entities);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcOnDifferentLayer()
{
var layer1 = new Layer("cut");
var layer2 = new Layer("etch");
var circle = new Circle(10, 10, 5) { Layer = layer1 };
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer2 };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
Assert.Contains(arc, entities);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcWithDifferentRadius()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(10, 10, 3, 0, Angle.ToRadians(90)) { Layer = layer };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcWithDifferentCenter()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(20, 20, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_NoCircles_NoChange()
{
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90));
var line = new Line(0, 0, 10, 0);
var entities = new List<Entity> { arc, line };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_MultipleArcsMatchOneCircle_RemovesAll()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc1 = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var arc2 = new Arc(10, 10, 5, Angle.ToRadians(90), Angle.ToRadians(180)) { Layer = layer };
var entities = new List<Entity> { circle, arc1, arc2 };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Single(entities);
Assert.Contains(circle, entities);
}
}
@@ -0,0 +1,245 @@
using System.Collections.Generic;
using CSMath;
using OpenNest.Geometry;
using OpenNest.IO;
using Xunit;
namespace OpenNest.Tests.IO
{
public class TitleBlockDetectorTests
{
private static Line MakeLine(double x1, double y1, double x2, double y2) =>
new Line(x1, y1, x2, y2);
[Fact]
public void DetectByLayerName_FlagsTitleLayer()
{
var line = MakeLine(0, 0, 10, 0);
line.Layer = new Layer("TITLE");
var entities = new List<Entity> { line };
var result = TitleBlockDetector.Detect(entities, null);
Assert.Contains(line.Id, result);
}
[Fact]
public void DetectByLayerName_CaseInsensitive()
{
var line = MakeLine(0, 0, 10, 0);
line.Layer = new Layer("border");
var entities = new List<Entity> { line };
var result = TitleBlockDetector.Detect(entities, null);
Assert.Contains(line.Id, result);
}
[Fact]
public void DetectByLayerName_IgnoresNonMatchingLayers()
{
var line = MakeLine(0, 0, 10, 0);
line.Layer = new Layer("0");
var entities = new List<Entity> { line };
var result = TitleBlockDetector.Detect(entities, null);
Assert.DoesNotContain(line.Id, result);
}
[Theory]
[InlineData("TITLE")]
[InlineData("TITLEBLOCK")]
[InlineData("TITLE_BLOCK")]
[InlineData("BORDER")]
[InlineData("FRAME")]
[InlineData("TB")]
[InlineData("INFO")]
[InlineData("SHEET")]
[InlineData("ANNOTATION")]
public void DetectByLayerName_AllKnownNames(string layerName)
{
var line = MakeLine(0, 0, 10, 0);
line.Layer = new Layer(layerName);
var entities = new List<Entity> { line };
var result = TitleBlockDetector.Detect(entities, null);
Assert.Contains(line.Id, result);
}
[Fact]
public void DetectBorder_FlagsLinesOnBoundingBoxEdges()
{
var entities = new List<Entity>
{
new Line(0, 0, 86, 0) { Layer = new Layer("0") },
new Line(86, 0, 86, 134) { Layer = new Layer("0") },
new Line(86, 134, 0, 134) { Layer = new Layer("0") },
new Line(0, 134, 0, 0) { Layer = new Layer("0") },
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
new Line(50, 90, 70, 40) { Layer = new Layer("0") },
new Line(70, 40, 30, 40) { Layer = new Layer("0") },
};
var result = TitleBlockDetector.Detect(entities, null);
Assert.Contains(entities[0].Id, result);
Assert.Contains(entities[1].Id, result);
Assert.Contains(entities[2].Id, result);
Assert.Contains(entities[3].Id, result);
Assert.DoesNotContain(entities[4].Id, result);
Assert.DoesNotContain(entities[5].Id, result);
Assert.DoesNotContain(entities[6].Id, result);
}
[Fact]
public void DetectBorder_FlagsZoneMarkerTicks()
{
var entities = new List<Entity>
{
new Line(0, 0, 100, 0) { Layer = new Layer("0") },
new Line(100, 0, 100, 80) { Layer = new Layer("0") },
new Line(100, 80, 0, 80) { Layer = new Layer("0") },
new Line(0, 80, 0, 0) { Layer = new Layer("0") },
new Line(25, 80, 25, 77) { Layer = new Layer("0") },
new Line(50, 80, 50, 77) { Layer = new Layer("0") },
new Line(75, 80, 75, 77) { Layer = new Layer("0") },
new Line(40, 30, 60, 30) { Layer = new Layer("0") },
};
var result = TitleBlockDetector.Detect(entities, null);
Assert.Contains(entities[4].Id, result);
Assert.Contains(entities[5].Id, result);
Assert.Contains(entities[6].Id, result);
Assert.DoesNotContain(entities[7].Id, result);
}
[Fact]
public void DetectBorder_IgnoresWhenNoBorderPresent()
{
var entities = new List<Entity>
{
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
new Line(50, 90, 70, 40) { Layer = new Layer("0") },
new Line(70, 40, 30, 40) { Layer = new Layer("0") },
};
var result = TitleBlockDetector.Detect(entities, null);
Assert.Empty(result);
}
[Fact]
public void DetectBorder_ToleratesSlightRotation()
{
var angleRad = OpenNest.Math.Angle.ToRadians(0.5);
var endY = 86 * System.Math.Sin(angleRad);
var entities = new List<Entity>
{
new Line(0, 0, 86, endY) { Layer = new Layer("0") },
new Line(86, endY, 86, 134) { Layer = new Layer("0") },
new Line(86, 134, 0, 134) { Layer = new Layer("0") },
new Line(0, 134, 0, 0) { Layer = new Layer("0") },
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
};
var result = TitleBlockDetector.Detect(entities, null);
Assert.Contains(entities[0].Id, result);
}
[Fact]
public void DetectTitleBlock_FlagsEntitiesInTextDenseCorner()
{
var partLine1 = new Line(5, 70, 25, 120) { Layer = new Layer("0") };
var partLine2 = new Line(25, 120, 45, 70) { Layer = new Layer("0") };
var partLine3 = new Line(45, 70, 5, 70) { Layer = new Layer("0") };
var tbLines = new List<Entity>();
for (var x = 50; x <= 85; x += 5)
tbLines.Add(new Line(x, 0, x, 30) { Layer = new Layer("0") });
for (var y = 0; y <= 30; y += 5)
tbLines.Add(new Line(50, y, 85, y) { Layer = new Layer("0") });
var entities = new List<Entity> { partLine1, partLine2, partLine3 };
entities.AddRange(tbLines);
var doc = BuildDocWithTexts(
(60, 5, "TITLE: Test Part"),
(60, 10, "DWG NO: 12345"),
(60, 15, "SCALE: 1:1"),
(60, 20, "REV: A"),
(60, 25, "MATERIAL: STEEL"));
var result = TitleBlockDetector.Detect(entities, doc);
foreach (var tb in tbLines)
Assert.Contains(tb.Id, result);
Assert.DoesNotContain(partLine1.Id, result);
Assert.DoesNotContain(partLine2.Id, result);
Assert.DoesNotContain(partLine3.Id, result);
}
[Fact]
public void DetectTitleBlock_NoFalsePositivesWithoutText()
{
var entities = new List<Entity>
{
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
new Line(50, 90, 70, 40) { Layer = new Layer("0") },
new Line(70, 40, 30, 40) { Layer = new Layer("0") },
};
var result = TitleBlockDetector.Detect(entities, null);
Assert.Empty(result);
}
[Fact]
public void DetectTitleBlock_BottomEdgeStrip()
{
var partLine = new Line(20, 40, 80, 40) { Layer = new Layer("0") };
var tbLines = new List<Entity>();
for (var x = 0; x <= 100; x += 10)
tbLines.Add(new Line(x, 0, x, 20) { Layer = new Layer("0") });
for (var y = 0; y <= 20; y += 5)
tbLines.Add(new Line(0, y, 100, y) { Layer = new Layer("0") });
var entities = new List<Entity> { partLine };
entities.AddRange(tbLines);
var doc = BuildDocWithTexts(
(10, 5, "TITLE"),
(30, 5, "DWG NO"),
(50, 5, "SCALE"),
(70, 5, "REV"),
(90, 5, "DATE"));
var result = TitleBlockDetector.Detect(entities, doc);
foreach (var tb in tbLines)
Assert.Contains(tb.Id, result);
Assert.DoesNotContain(partLine.Id, result);
}
private static ACadSharp.CadDocument BuildDocWithTexts(
params (double x, double y, string value)[] texts)
{
var doc = new ACadSharp.CadDocument();
foreach (var (x, y, value) in texts)
{
var mtext = new ACadSharp.Entities.MText
{
InsertPoint = new XYZ(x, y, 0),
Value = value,
Height = 2.0
};
doc.Entities.Add(mtext);
}
return doc;
}
}
}
+46
View File
@@ -0,0 +1,46 @@
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.Math;
public class FractionTests
{
[Theory]
[InlineData("3/8", 0.375)]
[InlineData("1 3/4", 1.75)]
[InlineData("1-3/4", 1.75)]
[InlineData("1/2", 0.5)]
public void Parse_ValidFraction_ReturnsDouble(string input, double expected)
{
var result = Fraction.Parse(input);
Assert.Equal(expected, result, 8);
}
[Theory]
[InlineData("3/8", true)]
[InlineData("abc", false)]
[InlineData("1 3/4", true)]
public void IsValid_ReturnsExpected(string input, bool expected)
{
Assert.Equal(expected, Fraction.IsValid(input));
}
[Fact]
public void TryParse_InvalidInput_ReturnsFalse()
{
var result = Fraction.TryParse("abc", out var value);
Assert.False(result);
Assert.Equal(0, value);
}
[Fact]
public void ReplaceFractionsWithDecimals_ReplacesFractionInString()
{
var result = Fraction.ReplaceFractionsWithDecimals("length is 1 3/4 inches");
Assert.Contains("1.75", result);
Assert.DoesNotContain("3/4", result);
}
}
+4 -2
View File
@@ -89,8 +89,10 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
new Size(48, 24), new Size(120, 10)
};
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories);
Console.WriteLine($"Found {dxfFiles.Length} DXF files");
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories)
.Concat(Directory.GetFiles(dir, "*.dwg", SearchOption.AllDirectories))
.ToArray();
Console.WriteLine($"Found {dxfFiles.Length} CAD files");
var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db";
Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}");
Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations");
-18
View File
@@ -16,15 +16,11 @@ namespace OpenNest.Actions
private CutOffSettings settings;
private CutOffAxis lockedAxis = CutOffAxis.Vertical;
private Dictionary<Part, Entity> perimeterCache;
private readonly Timer debounceTimer;
private bool regeneratePending;
public ActionCutOff(PlateView plateView)
: base(plateView)
{
settings = plateView.CutOffSettings;
debounceTimer = new Timer { Interval = 16 };
debounceTimer.Tick += OnDebounce;
ConnectEvents();
}
@@ -40,8 +36,6 @@ namespace OpenNest.Actions
public override void DisconnectEvents()
{
debounceTimer.Stop();
debounceTimer.Dispose();
plateView.MouseMove -= OnMouseMove;
plateView.MouseDown -= OnMouseDown;
plateView.KeyDown -= OnKeyDown;
@@ -58,18 +52,6 @@ namespace OpenNest.Actions
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;
previewCutOff = new CutOff(pt, lockedAxis);
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
+1 -1
View File
@@ -1,4 +1,4 @@
using OpenNest.IO.Bom;
using OpenNest.Math;
using System;
using System.Drawing;
using System.Text;
+16
View File
@@ -0,0 +1,16 @@
using System.Drawing;
using OpenNest.Geometry;
namespace OpenNest.Controls
{
public class CadText
{
public Vector Position { get; set; }
public string Value { get; set; }
public double Height { get; set; }
public double Rotation { get; set; }
public string LayerName { get; set; }
public StringAlignment HAlign { get; set; }
public StringAlignment VAlign { get; set; }
}
}
+78
View File
@@ -29,12 +29,17 @@ namespace OpenNest.Controls
public List<Entity> SimplifierToleranceRight { get; set; }
public List<Entity> OriginalEntities { 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 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 SolidBrush labelBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
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 PickCancelled;
@@ -100,6 +105,13 @@ namespace OpenNest.Controls
foreach (var entity in Entities)
{
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 pen = isHighlighted
? GetEntityPen(Color.FromArgb(60, entity.Color))
@@ -116,6 +128,8 @@ namespace OpenNest.Controls
DrawEntity(e.Graphics, entity, pen);
}
DrawTexts(e.Graphics);
if (ShowEntityLabels)
DrawEntityLabels(e.Graphics);
@@ -239,11 +253,26 @@ namespace OpenNest.Controls
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()
{
foreach (var pen in penCache.Values)
pen.Dispose();
penCache.Clear();
foreach (var pen in ghostPenCache.Values)
pen.Dispose();
ghostPenCache.Clear();
}
private static bool IsEtchLayer(Layer layer) =>
@@ -408,10 +437,29 @@ namespace OpenNest.Controls
labelFont.Dispose();
labelBrush.Dispose();
labelBackBrush.Dispose();
textBrush.Dispose();
ghostTextBrush.Dispose();
}
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)
{
if (!e.Layer.IsVisible || !e.IsVisible)
@@ -474,6 +522,36 @@ namespace OpenNest.Controls
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)
{
var pt1 = PointWorldToGraph(pt);
+2
View File
@@ -20,8 +20,10 @@ namespace OpenNest.Controls
public List<Entity> OriginalEntities { get; set; }
public List<Bend> Bends { get; set; } = new();
public HashSet<Guid> SuppressedEntityIds { get; set; }
public HashSet<Guid> TitleBlockEntityIds { get; set; }
public Box Bounds { get; set; }
public int EntityCount { get; set; }
public List<CadText> Texts { get; set; } = new();
}
public class FileListControl : Control
+2 -1
View File
@@ -165,7 +165,8 @@ namespace OpenNest.Forms
else
{
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);
if (matchedPaths.TryGetValue(lookupName, out var dxfPath))
+110 -2
View File
@@ -92,9 +92,18 @@ namespace OpenNest.Forms
Customer = string.Empty,
Bends = result.Bends,
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)
BeginInvoke((Action)(() => fileList.AddItem(item)));
else
@@ -152,6 +161,8 @@ namespace OpenNest.Forms
entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities);
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);
if (item.Entities.Any(e => e.Layer != null))
@@ -473,7 +484,8 @@ namespace OpenNest.Forms
{
var files = (string[])e.Data.GetData(DataFormats.FileDrop);
var dxfFiles = files.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToArray();
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToArray();
if (dxfFiles.Length > 0)
AddFiles(dxfFiles);
}
@@ -803,6 +815,102 @@ namespace OpenNest.Forms
#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)
{
+1 -2
View File
@@ -329,7 +329,7 @@ namespace OpenNest.Forms
{
var dlg = new OpenFileDialog();
dlg.Multiselect = true;
dlg.Filter = "DXF Files (*.dxf) | *.dxf";
dlg.Filter = "CAD Files (*.dxf;*.dwg)|*.dxf;*.dwg|DXF Files (*.dxf)|*.dxf|DWG Files (*.dwg)|*.dwg";
if (dlg.ShowDialog() != DialogResult.OK)
return;
@@ -346,7 +346,6 @@ namespace OpenNest.Forms
drawings.ForEach(d => Nest.Drawings.Add(d));
UpdateDrawingList();
tabControl1.SelectedIndex = 1;
}
public bool Export()
+22 -2
View File
@@ -138,9 +138,20 @@ namespace OpenNest
break;
case CodeType.RapidMove:
{
var rapid = (RapidMove)code;
var endpt = rapid.EndPoint;
if (mode == Mode.Incremental)
endpt += curpos;
var dx = endpt.X - curpos.X;
var dy = endpt.Y - curpos.Y;
if (dx * dx + dy * dy > 0.001 * 0.001)
{
cutPath.StartFigure();
leadPath.StartFigure();
AddLine(cutPath, (RapidMove)code, mode, ref curpos);
}
curpos = endpt;
}
break;
case CodeType.SubProgramCall:
@@ -300,8 +311,17 @@ namespace OpenNest
break;
case CodeType.RapidMove:
{
var rapid = (RapidMove)code;
var endpt = rapid.EndPoint;
if (mode == Mode.Incremental)
endpt += curpos;
var dx = endpt.X - curpos.X;
var dy = endpt.Y - curpos.Y;
if (dx * dx + dy * dy > 0.001 * 0.001)
Flush();
AddLine(path, (RapidMove)code, mode, ref curpos);
curpos = endpt;
}
break;
case CodeType.SubProgramCall: