10 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
13 changed files with 701 additions and 20 deletions
+3
View File
@@ -93,6 +93,9 @@ namespace OpenNest.Geometry
} }
} }
public bool IsFullCircle() =>
SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
/// <summary> /// <summary>
/// Angle in radians between start and end angles. /// Angle in radians between start and end angles.
/// </summary> /// </summary>
@@ -17,6 +17,38 @@ namespace OpenNest.Geometry
(list, item, i) => list.GetCollinearLines(item, i), (list, item, i) => list.GetCollinearLines(item, i),
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)); (Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined));
public static void Deduplicate(IList<Circle> circles)
{
for (var i = circles.Count - 1; i >= 1; i--)
{
for (var j = i - 1; j >= 0; j--)
{
if (circles[i].Center.DistanceTo(circles[j].Center) <= Tolerance.Epsilon
&& circles[i].Radius.IsEqualTo(circles[j].Radius))
{
circles.RemoveAt(i);
break;
}
}
}
}
public static void Deduplicate(IList<Circle> circles, IList<Arc> arcs)
{
for (var i = circles.Count - 1; i >= 0; i--)
{
for (var j = arcs.Count - 1; j >= 0; j--)
{
if (arcs[j].Center.DistanceTo(circles[i].Center) <= Tolerance.Epsilon
&& arcs[j].Radius.IsEqualTo(circles[i].Radius)
&& arcs[j].IsFullCircle())
{
arcs.RemoveAt(j);
}
}
}
}
private delegate bool TryJoin<T>(T a, T b, out T joined); private delegate bool TryJoin<T>(T a, T b, out T joined);
private static void MergePass<T>(IList<T> items, private static void MergePass<T>(IList<T> items,
+5
View File
@@ -16,6 +16,11 @@ namespace OpenNest.IO
/// </summary> /// </summary>
public bool DetectBends { get; set; } = true; public bool DetectBends { get; set; } = true;
/// <summary>
/// When true, detects and identifies title block entities during import. Default true.
/// </summary>
public bool DetectTitleBlock { get; set; } = true;
/// <summary> /// <summary>
/// Override the drawing name. Null = filename without extension. /// Override the drawing name. Null = filename without extension.
/// </summary> /// </summary>
+5
View File
@@ -45,5 +45,10 @@ namespace OpenNest.IO
/// that need access to non-geometry entities (e.g., text annotations). /// that need access to non-geometry entities (e.g., text annotations).
/// </summary> /// </summary>
public CadDocument Document { get; set; } 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; }
} }
} }
+22
View File
@@ -27,6 +27,7 @@ namespace OpenNest.IO
var dxf = Dxf.Import(path); var dxf = Dxf.Import(path);
RemoveDuplicateArcs(dxf.Entities); 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)
@@ -40,6 +41,10 @@ namespace OpenNest.IO
Bend.UpdateEtchEntities(dxf.Entities, bends); Bend.UpdateEtchEntities(dxf.Entities, bends);
HashSet<System.Guid> titleBlockIds = null;
if (options.DetectTitleBlock)
titleBlockIds = TitleBlockDetector.Detect(dxf.Entities, dxf.Document);
return new CadImportResult return new CadImportResult
{ {
Entities = dxf.Entities, Entities = dxf.Entities,
@@ -48,6 +53,7 @@ namespace OpenNest.IO
SourcePath = path, SourcePath = path,
Name = options.Name ?? Path.GetFileNameWithoutExtension(path), Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
Document = dxf.Document, Document = dxf.Document,
TitleBlockEntityIds = titleBlockIds,
}; };
} }
@@ -138,9 +144,25 @@ 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) internal static void RemoveDuplicateArcs(List<Entity> entities)
{ {
var circles = entities.OfType<Circle>().ToList(); var circles = entities.OfType<Circle>().ToList();
+5 -1
View File
@@ -133,6 +133,7 @@ namespace OpenNest.IO
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)
{ {
@@ -150,7 +151,7 @@ namespace OpenNest.IO
break; break;
case ACadSharp.Entities.Circle circle: case ACadSharp.Entities.Circle circle:
entities.Add(circle.ToOpenNest()); circles.Add(circle.ToOpenNest());
break; break;
case ACadSharp.Entities.Spline spline: case ACadSharp.Entities.Spline spline:
@@ -181,7 +182,10 @@ namespace OpenNest.IO
GeometryOptimizer.Optimize(lines); GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs); GeometryOptimizer.Optimize(arcs);
GeometryOptimizer.Deduplicate(circles);
GeometryOptimizer.Deduplicate(circles, arcs);
entities.AddRange(circles);
entities.AddRange(lines); entities.AddRange(lines);
entities.AddRange(arcs); entities.AddRange(arcs);
+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;
}
}
}
+16
View File
@@ -134,5 +134,21 @@ namespace OpenNest.Tests.IO
Assert.NotNull(drawing.Program); Assert.NotNull(drawing.Program);
Assert.NotNull(drawing.SourceEntities); Assert.NotNull(drawing.SourceEntities);
} }
[Fact]
public void Import_WhenDetectTitleBlockTrue_PopulatesTitleBlockEntityIds()
{
var result = CadImporter.Import(TestDxf);
Assert.NotNull(result.TitleBlockEntityIds);
}
[Fact]
public void Import_WhenDetectTitleBlockFalse_TitleBlockEntityIdsIsNull()
{
var result = CadImporter.Import(TestDxf, new CadImportOptions { DetectTitleBlock = false });
Assert.Null(result.TitleBlockEntityIds);
}
} }
} }
@@ -0,0 +1,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;
}
}
}
-18
View File
@@ -16,15 +16,11 @@ namespace OpenNest.Actions
private CutOffSettings settings; private CutOffSettings settings;
private CutOffAxis lockedAxis = CutOffAxis.Vertical; private CutOffAxis lockedAxis = CutOffAxis.Vertical;
private Dictionary<Part, Entity> perimeterCache; private Dictionary<Part, Entity> perimeterCache;
private readonly Timer debounceTimer;
private bool regeneratePending;
public ActionCutOff(PlateView plateView) public ActionCutOff(PlateView plateView)
: base(plateView) : base(plateView)
{ {
settings = plateView.CutOffSettings; settings = plateView.CutOffSettings;
debounceTimer = new Timer { Interval = 16 };
debounceTimer.Tick += OnDebounce;
ConnectEvents(); ConnectEvents();
} }
@@ -40,8 +36,6 @@ namespace OpenNest.Actions
public override void DisconnectEvents() public override void DisconnectEvents()
{ {
debounceTimer.Stop();
debounceTimer.Dispose();
plateView.MouseMove -= OnMouseMove; plateView.MouseMove -= OnMouseMove;
plateView.MouseDown -= OnMouseDown; plateView.MouseDown -= OnMouseDown;
plateView.KeyDown -= OnKeyDown; plateView.KeyDown -= OnKeyDown;
@@ -58,18 +52,6 @@ namespace OpenNest.Actions
private void OnMouseMove(object sender, MouseEventArgs e) private void OnMouseMove(object sender, MouseEventArgs e)
{ {
regeneratePending = true;
debounceTimer.Start();
}
private void OnDebounce(object sender, System.EventArgs e)
{
debounceTimer.Stop();
if (!regeneratePending)
return;
regeneratePending = false;
var pt = plateView.CurrentPoint; var pt = plateView.CurrentPoint;
previewCutOff = new CutOff(pt, lockedAxis); previewCutOff = new CutOff(pt, lockedAxis);
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache); previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
+46 -1
View File
@@ -30,13 +30,16 @@ namespace OpenNest.Controls
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 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 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;
@@ -102,6 +105,13 @@ namespace OpenNest.Controls
foreach (var entity in Entities) foreach (var entity in Entities)
{ {
if (IsEtchLayer(entity.Layer)) continue; if (IsEtchLayer(entity.Layer)) continue;
if (TitleBlockEntityIds != null && TitleBlockEntityIds.Contains(entity.Id))
{
DrawGhostEntity(e.Graphics, entity);
continue;
}
var isHighlighted = simplifierHighlightSet != null && simplifierHighlightSet.Contains(entity); var isHighlighted = simplifierHighlightSet != null && simplifierHighlightSet.Contains(entity);
var pen = isHighlighted var pen = isHighlighted
? GetEntityPen(Color.FromArgb(60, entity.Color)) ? GetEntityPen(Color.FromArgb(60, entity.Color))
@@ -243,11 +253,26 @@ namespace OpenNest.Controls
return pen; return pen;
} }
private Pen GetGhostPen(Color color)
{
var ghostColor = Color.FromArgb(60, color.R, color.G, color.B);
var argb = ghostColor.ToArgb();
if (!ghostPenCache.TryGetValue(argb, out var pen))
{
pen = new Pen(ghostColor);
ghostPenCache[argb] = pen;
}
return pen;
}
public void ClearPenCache() public void ClearPenCache()
{ {
foreach (var pen in penCache.Values) foreach (var pen in penCache.Values)
pen.Dispose(); pen.Dispose();
penCache.Clear(); penCache.Clear();
foreach (var pen in ghostPenCache.Values)
pen.Dispose();
ghostPenCache.Clear();
} }
private static bool IsEtchLayer(Layer layer) => private static bool IsEtchLayer(Layer layer) =>
@@ -413,10 +438,28 @@ namespace OpenNest.Controls
labelBrush.Dispose(); labelBrush.Dispose();
labelBackBrush.Dispose(); labelBackBrush.Dispose();
textBrush.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)
@@ -501,8 +544,10 @@ namespace OpenNest.Controls
sf.Alignment = text.HAlign; sf.Alignment = text.HAlign;
sf.LineAlignment = text.VAlign; sf.LineAlignment = text.VAlign;
var brush = TitleBlockEntityIds != null && TitleBlockEntityIds.Count > 0
? ghostTextBrush : textBrush;
using var font = new Font("Segoe UI", fontSize, GraphicsUnit.Pixel); using var font = new Font("Segoe UI", fontSize, GraphicsUnit.Pixel);
g.DrawString(text.Value, font, textBrush, 0, 0, sf); g.DrawString(text.Value, font, brush, 0, 0, sf);
g.Restore(state); g.Restore(state);
} }
} }
+1
View File
@@ -20,6 +20,7 @@ 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 List<CadText> Texts { get; set; } = new();
+9
View File
@@ -94,8 +94,16 @@ namespace OpenNest.Forms
Bounds = result.Bounds, Bounds = result.Bounds,
EntityCount = result.Entities.Count, EntityCount = result.Entities.Count,
Texts = ExtractTexts(result.Document), 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
@@ -154,6 +162,7 @@ namespace OpenNest.Forms
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.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))