feat(io): add title block region detection with corner/edge scoring (phase 3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 11:50:45 -04:00
parent 09a5339b51
commit 4f2a8d29d5
2 changed files with 275 additions and 0 deletions
+182
View File
@@ -19,6 +19,8 @@ namespace OpenNest.IO
var flagged = new HashSet<Guid>(); var flagged = new HashSet<Guid>();
DetectByLayerName(entities, flagged); DetectByLayerName(entities, flagged);
DetectBorder(entities, flagged); DetectBorder(entities, flagged);
if (document != null)
DetectTitleBlockRegion(entities, document, flagged);
return flagged; return flagged;
} }
@@ -126,5 +128,185 @@ namespace OpenNest.IO
flagged.Add(line.Id); 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;
}
} }
} }
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using CSMath;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO; using OpenNest.IO;
using Xunit; using Xunit;
@@ -148,5 +149,97 @@ namespace OpenNest.Tests.IO
Assert.Contains(entities[0].Id, result); 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;
}
} }
} }