4f2a8d29d5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
313 lines
12 KiB
C#
313 lines
12 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|