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:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user