Files
mech-ai/tools/drawing-diagnostics/AutoCadDwgExtractor/Program.cs
T
2026-07-17 17:45:56 +08:00

4543 lines
185 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Win32;
try
{
var options = CliOptions.Parse(args);
if (string.IsNullOrWhiteSpace(options.DrawingPath) || !File.Exists(options.DrawingPath))
throw new FileNotFoundException($"DWG 文件不存在:{options.DrawingPath}");
Directory.CreateDirectory(options.OutputDir);
var result = string.IsNullOrWhiteSpace(options.ReuseExtractionDir)
? StaRunner.Run(() => AutoCadExtractor.Extract(options))
: AutoCadExtractor.ReanalyzeExistingExtraction(options);
Console.WriteLine(JsonSerializer.Serialize(result, JsonDefaults.Options));
}
catch (Exception ex)
{
Environment.ExitCode = 1;
Console.Error.WriteLine(ex.ToString());
Console.WriteLine(JsonSerializer.Serialize(new Dictionary<string, object?>
{
["ok"] = false,
["error_type"] = ex.GetType().FullName,
["message"] = ex.Message
}, JsonDefaults.Options));
}
sealed class AutoCadExtractor
{
const double AngleToleranceDeg = 1.0;
const double CollinearDistanceTolerance = 0.05;
const double GapTolerance = 0.05;
const double MainViewConnectTolerance = 0.5;
const double ViewRegionProbeTolerance = 0.5;
const double ViewRegionSpatialExpansionTolerance = 12.0;
public static ExtractionResult Extract(CliOptions options)
{
LogProgress("connect_autocad_start");
var app = ConnectOrCreate(options.ProgId, options.Visible, options.AllowStartAutoCad, out var started);
LogProgress("connect_autocad_ok");
object? doc = null;
try
{
LogProgress("wait_autocad_start");
WaitForQuiescent(app, TimeSpan.FromSeconds(30));
LogProgress("open_document_start");
doc = OpenDocument(app, options.DrawingPath);
LogProgress("open_document_ok");
WaitForQuiescent(app, TimeSpan.FromSeconds(30));
LogProgress("read_spline_endpoints_start");
var splineEndpoints = ReadSplineEndpointsViaAutoLisp(app, doc, options.OutputDir);
LogProgress("read_spline_endpoints_ok");
LogProgress("read_block_explosions_start");
var blockExplosions = ReadBlockExplosionsViaAutoLisp(app, doc, options.OutputDir);
LogProgress("read_block_explosions_ok");
LogProgress("read_dimension_definition_points_start");
var dimensionDefinitionPoints = ReadDimensionDefinitionPointsViaAutoLisp(app, doc, options.OutputDir);
LogProgress("read_dimension_definition_points_ok");
var extraction = new DocumentExtraction();
LogProgress("extract_modelspace_start");
ExtractSpace(doc, "ModelSpace", extraction, options.MaxEntities, splineEndpoints, blockExplosions, dimensionDefinitionPoints);
LogProgress("extract_modelspace_ok");
LogProgress("extract_paperspace_start");
ExtractSpace(doc, "PaperSpace", extraction, options.MaxEntities, splineEndpoints, blockExplosions, dimensionDefinitionPoints);
LogProgress("extract_paperspace_ok");
LogProgress("select_main_view_start");
SelectMainView(extraction, options.MainViewSeedMode, options.MainViewReferenceSelectionPath);
LogProgress("select_main_view_ok");
LogProgress("analyze_view_regions_start");
AnalyzeViewRegions(extraction, options);
LogProgress("analyze_view_regions_ok");
LogProgress("normalize_geometry_start");
NormalizeGeometry(extraction);
LogProgress("normalize_geometry_ok");
if (!string.IsNullOrWhiteSpace(options.SaveMainViewOnlyDwgPath))
{
var keptHandles = extraction.MainViewGeometry
.Select(item => ReadDictString(item, "handle"))
.Where(handle => !string.IsNullOrWhiteSpace(handle))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var savePath = Path.GetFullPath(options.SaveMainViewOnlyDwgPath);
var deleteResult = SaveMainViewOnlyDwg(doc, savePath, keptHandles);
extraction.MainViewSelection["main_view_only_dwg_path"] = savePath;
extraction.MainViewSelection["main_view_only_deleted_entity_count"] = deleteResult.DeletedCount;
extraction.MainViewSelection["main_view_only_kept_handle_count"] = keptHandles.Count;
}
LogProgress("write_files_start");
var manifest = WriteFiles(options.OutputDir, options.DrawingPath, options.ProgId, started, extraction);
LogProgress("write_files_ok");
if (options.CloseAfterExtract)
{
LogProgress("close_document_start");
TryInvoke(doc, "Close", [false]);
LogProgress("close_document_ok");
}
return new ExtractionResult
{
Ok = true,
Source = "AutoCAD COM external extractor",
ProgId = options.ProgId,
Started = started,
DrawingPath = options.DrawingPath,
ExportDir = options.OutputDir,
ManifestPath = Path.Combine(options.OutputDir, "manifest.json"),
EntityCount = extraction.EntitiesCommon.Count,
TypeCounts = extraction.TypeCounts,
CategoryFiles = manifest.CategoryFiles,
Message = $"已通过 AutoCAD COM 提取 DWG{Path.GetFileName(options.DrawingPath)}"
};
}
finally
{
if (doc != null)
ReleaseCom(doc);
ReleaseCom(app);
}
}
public static ExtractionResult ReanalyzeExistingExtraction(CliOptions options)
{
LogProgress("reuse_extraction_load_start");
var extraction = LoadExistingExtraction(options.ReuseExtractionDir);
LogProgress("reuse_extraction_load_ok");
LogProgress("select_main_view_start");
SelectMainView(extraction, options.MainViewSeedMode, options.MainViewReferenceSelectionPath);
LogProgress("select_main_view_ok");
LogProgress("analyze_view_regions_start");
AnalyzeViewRegions(extraction, options);
LogProgress("analyze_view_regions_ok");
LogProgress("normalize_geometry_start");
NormalizeGeometry(extraction);
LogProgress("normalize_geometry_ok");
LogProgress("write_files_start");
var manifest = WriteFiles(options.OutputDir, options.DrawingPath, options.ProgId, false, extraction);
LogProgress("write_files_ok");
return new ExtractionResult
{
Ok = true,
Source = "Existing AutoCAD extraction reanalysis",
ProgId = options.ProgId,
Started = false,
DrawingPath = options.DrawingPath,
ExportDir = options.OutputDir,
ManifestPath = Path.Combine(options.OutputDir, "manifest.json"),
EntityCount = extraction.EntitiesCommon.Count,
TypeCounts = extraction.TypeCounts,
CategoryFiles = manifest.CategoryFiles,
Message = $"Reanalyzed existing extraction: {Path.GetFileName(options.ReuseExtractionDir)}"
};
}
static void LogProgress(string stage) =>
Console.Error.WriteLine($"[{DateTime.Now:HH:mm:ss}] AutoCadDwgExtractor {stage}");
static void ExtractSpace(
object doc,
string spaceName,
DocumentExtraction extraction,
int maxEntities,
Dictionary<string, SplineEndpoint> splineEndpoints,
Dictionary<string, List<Dictionary<string, object?>>> blockExplosions,
Dictionary<string, DimensionDefinitionPoints> dimensionDefinitionPoints)
{
var space = GetProperty(doc, spaceName);
if (space == null)
return;
try
{
var count = ToInt(GetProperty(space, "Count")) ?? 0;
if (spaceName.Equals("ModelSpace", StringComparison.OrdinalIgnoreCase))
extraction.ModelSpaceCount = count;
else
extraction.PaperSpaceCount = count;
for (var i = 0; i < count && extraction.EntitiesCommon.Count < maxEntities; i++)
{
var entity = GetCollectionItem(space, i);
if (entity == null)
continue;
try
{
ExtractEntity(entity, spaceName, i, extraction, splineEndpoints, blockExplosions, dimensionDefinitionPoints);
}
finally
{
ReleaseCom(entity);
}
}
}
finally
{
ReleaseCom(space);
}
}
static void ExtractEntity(
object entity,
string spaceName,
int index,
DocumentExtraction extraction,
Dictionary<string, SplineEndpoint> splineEndpoints,
Dictionary<string, List<Dictionary<string, object?>>> blockExplosions,
Dictionary<string, DimensionDefinitionPoints> dimensionDefinitionPoints)
{
var objectName = ReadString(entity, "ObjectName");
if (string.IsNullOrWhiteSpace(objectName))
objectName = entity.GetType().Name;
extraction.TypeCounts[objectName] = extraction.TypeCounts.TryGetValue(objectName, out var count) ? count + 1 : 1;
var common = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["space"] = spaceName,
["index"] = index,
["object_name"] = objectName,
["handle"] = ReadString(entity, "Handle"),
["layer"] = ReadString(entity, "Layer"),
["linetype"] = ReadString(entity, "Linetype"),
["color"] = ToInt(GetProperty(entity, "Color")),
["lineweight"] = ToInt(GetProperty(entity, "Lineweight"))
};
extraction.EntitiesCommon.Add(common);
var details = ExtractDetails(entity, objectName);
var handle = ReadDictString(common, "handle");
if (objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(handle) &&
splineEndpoints.TryGetValue(handle, out var splineEndpoint))
{
details["start"] = splineEndpoint.Start;
details["end"] = splineEndpoint.End;
details["spline_endpoint_source"] = splineEndpoint.Source;
}
if (objectName.Contains("BlockReference", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(handle) &&
blockExplosions.TryGetValue(handle, out var exploded))
{
details["exploded_entities"] = exploded;
details["exploded_entity_count"] = exploded.Count;
}
if (objectName.Contains("Dimension", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(handle) &&
dimensionDefinitionPoints.TryGetValue(handle, out var definitionPoints))
{
ApplyDimensionDefinitionPoints(details, definitionPoints);
}
var merged = Merge(common, details);
if (objectName.Contains("Dimension", StringComparison.OrdinalIgnoreCase))
{
extraction.Dimensions.Add(merged);
var tol = ExtractDimensionalTolerance(common, details);
if (tol != null)
extraction.DimensionalTolerances.Add(tol);
}
else if (IsGeometry(objectName))
{
extraction.Geometry.Add(merged);
}
else if (IsAnnotation(objectName))
{
extraction.Annotations.Add(merged);
}
else
{
extraction.UnknownEntities.Add(merged);
}
var text = details.TryGetValue("text", out var t) ? Convert.ToString(t, CultureInfo.InvariantCulture) ?? "" : "";
var blockName = details.TryGetValue("effective_name", out var b) ? Convert.ToString(b, CultureInfo.InvariantCulture) ?? "" : "";
if (LooksLikeRoughness(text, blockName))
extraction.Roughness.Add(merged);
if (LooksLikeGeometricTolerance(text, blockName))
extraction.GeometricTolerances.Add(merged);
if (LooksLikeDatum(text, blockName))
extraction.Datums.Add(merged);
}
static Dictionary<string, object?> ExtractDetails(object entity, string objectName)
{
var details = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
var bbox = ReadEntityBoundingBox(entity);
if (bbox != null)
details["bbox"] = bbox;
if (IsPolyline(objectName))
{
var coordinates = DoubleList(GetProperty(entity, "Coordinates"));
details["coordinates"] = coordinates;
details["closed"] = ToBool(GetProperty(entity, "Closed"));
details["elevation"] = ToDouble(GetProperty(entity, "Elevation"));
details["bulges"] = ReadPolylineBulges(entity, coordinates.Count);
}
else if (objectName.Contains("Circle", StringComparison.OrdinalIgnoreCase))
{
AddPoint(details, "center", GetProperty(entity, "Center"));
var radius = ToDouble(GetProperty(entity, "Radius"));
details["radius"] = radius;
details["diameter"] = radius == null ? null : radius * 2;
}
else if (objectName.Contains("Arc", StringComparison.OrdinalIgnoreCase))
{
AddPoint(details, "center", GetProperty(entity, "Center"));
details["radius"] = ToDouble(GetProperty(entity, "Radius"));
details["start_angle"] = ToDouble(GetProperty(entity, "StartAngle"));
details["end_angle"] = ToDouble(GetProperty(entity, "EndAngle"));
}
else if (objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase))
{
AddPoint(details, "start", GetProperty(entity, "StartPoint"));
AddPoint(details, "end", GetProperty(entity, "EndPoint"));
var fitPoints = DoubleList(GetProperty(entity, "FitPoints"));
var controlPoints = DoubleList(GetProperty(entity, "ControlPoints"));
if (fitPoints.Count > 0)
details["fit_points"] = fitPoints;
if (controlPoints.Count > 0)
details["control_points"] = controlPoints;
EnsureSplineEndpoint(details, "start", fitPoints, controlPoints, takeFirst: true);
EnsureSplineEndpoint(details, "end", fitPoints, controlPoints, takeFirst: false);
details["degree"] = ToInt(GetProperty(entity, "Degree"));
details["closed"] = ToBool(GetProperty(entity, "Closed"));
}
else if (objectName.Contains("Line", StringComparison.OrdinalIgnoreCase))
{
AddPoint(details, "start", GetProperty(entity, "StartPoint"));
AddPoint(details, "end", GetProperty(entity, "EndPoint"));
}
else if (objectName.Contains("Text", StringComparison.OrdinalIgnoreCase))
{
var raw = ReadString(entity, "TextString");
details["text"] = CleanAcadText(raw);
details["raw_text"] = raw;
details["height"] = ToDouble(GetProperty(entity, "Height"));
details["rotation"] = ToDouble(GetProperty(entity, "Rotation"));
AddPoint(details, "insertion_point", GetProperty(entity, "InsertionPoint"));
}
else if (objectName.Contains("Dimension", StringComparison.OrdinalIgnoreCase))
{
var raw = ReadString(entity, "TextOverride");
details["measurement"] = ToDouble(GetProperty(entity, "Measurement"));
details["text_override"] = raw;
details["text"] = CleanAcadText(raw);
var missingPointFields = new List<string>();
AddPointFromProperties(details, "text_position", entity, missingPointFields, "TextPosition");
AddPointFromProperties(details, "xline1_point", entity, missingPointFields, "XLine1Point", "ExtLine1Point", "ExtensionLine1Point");
AddPointFromProperties(details, "xline2_point", entity, missingPointFields, "XLine2Point", "ExtLine2Point", "ExtensionLine2Point");
AddPointFromProperties(details, "dim_line_point", entity, missingPointFields, "DimLinePoint", "DimLineLocation");
AddPointFromProperties(details, "center", entity, missingPointFields, "Center");
AddPointFromProperties(details, "chord_point", entity, missingPointFields, "ChordPoint");
AddPointFromProperties(details, "far_chord_point", entity, missingPointFields, "FarChordPoint");
if (missingPointFields.Count > 0)
details["dimension_point_missing_fields"] = missingPointFields;
}
else if (objectName.Contains("BlockReference", StringComparison.OrdinalIgnoreCase))
{
details["name"] = ReadString(entity, "Name");
details["effective_name"] = ReadString(entity, "EffectiveName");
details["rotation"] = ToDouble(GetProperty(entity, "Rotation"));
AddPoint(details, "insertion_point", GetProperty(entity, "InsertionPoint"));
var attributes = ReadBlockAttributes(entity);
if (attributes.Count > 0)
{
details["attributes"] = attributes;
details["text"] = string.Join(" ", attributes.Select(a => a.TryGetValue("value", out var value) ? value : null));
}
}
else if (objectName.Contains("Hatch", StringComparison.OrdinalIgnoreCase))
{
details["pattern_name"] = ReadString(entity, "PatternName");
details["pattern_scale"] = ToDouble(GetProperty(entity, "PatternScale"));
}
return details;
}
static Dictionary<string, object?>? ReadEntityBoundingBox(object entity)
{
try
{
object? minPoint = null;
object? maxPoint = null;
object?[] args = [minPoint, maxPoint];
var modifiers = new ParameterModifier(2);
modifiers[0] = true;
modifiers[1] = true;
entity.GetType().InvokeMember(
"GetBoundingBox",
BindingFlags.InvokeMethod,
null,
entity,
args,
[modifiers],
CultureInfo.InvariantCulture,
null);
var min = DoubleList(args[0]);
var max = DoubleList(args[1]);
if (min.Count < 2 || max.Count < 2)
return null;
return new Dictionary<string, object?>
{
["min_x"] = Math.Min(min[0], max[0]),
["min_y"] = Math.Min(min[1], max[1]),
["max_x"] = Math.Max(min[0], max[0]),
["max_y"] = Math.Max(min[1], max[1])
};
}
catch
{
return null;
}
}
static List<Dictionary<string, object?>> ReadBlockAttributes(object entity)
{
var result = new List<Dictionary<string, object?>>();
if (ToBool(GetProperty(entity, "HasAttributes")) != true)
return result;
var raw = TryInvoke(entity, "GetAttributes", []);
if (raw is not Array array)
return result;
foreach (var item in array)
{
if (item == null)
continue;
result.Add(new Dictionary<string, object?>
{
["tag"] = ReadString(item, "TagString"),
["value"] = CleanAcadText(ReadString(item, "TextString")),
["raw_value"] = ReadString(item, "TextString")
});
ReleaseCom(item);
}
return result;
}
static Dictionary<string, object?>? ExtractDimensionalTolerance(Dictionary<string, object?> common, Dictionary<string, object?> details)
{
var raw = details.TryGetValue("text_override", out var rawValue) ? Convert.ToString(rawValue, CultureInfo.InvariantCulture) ?? "" : "";
var text = details.TryGetValue("text", out var textValue) ? Convert.ToString(textValue, CultureInfo.InvariantCulture) ?? "" : "";
if (!DimensionalToleranceParser.LooksLikeTolerance(raw, text))
return null;
var measurement = details.TryGetValue("measurement", out var measurementValue)
? ToDouble(measurementValue)
: null;
var objectName = common.TryGetValue("object_name", out var objectNameValue)
? Convert.ToString(objectNameValue, CultureInfo.InvariantCulture) ?? ""
: "";
var parsed = DimensionalToleranceParser.Parse(text, raw, measurement, objectName);
var result = Merge(Merge(common, details), parsed);
result["measurement"] = measurementValue;
result["numeric_values"] = ExtractNumbers(text);
return result;
}
static void NormalizeGeometry(DocumentExtraction extraction)
{
var full = NormalizePrimitiveSet(extraction.Geometry, "full_drawing");
extraction.NormalizedGeometry = full.Normalized;
extraction.MergedLines = full.MergedLines;
extraction.GeometryOverlaps = full.Overlaps;
extraction.NormalizationSummary = full.Summary;
var main = NormalizePrimitiveSet(extraction.MainViewGeometry, "main_view");
extraction.MainViewNormalizedGeometry = main.Normalized;
extraction.MainViewMergedLines = main.MergedLines;
extraction.MainViewOverlaps = main.Overlaps;
extraction.MainViewSignature = BuildViewSignature(
extraction.MainViewNormalizedGeometry,
extraction.MainViewMergedLines,
"main_view");
foreach (var item in main.Summary)
extraction.MainViewSelection["normalization_" + item.Key] = item.Value;
}
static NormalizedGeometrySet NormalizePrimitiveSet(List<Dictionary<string, object?>> geometry, string scope)
{
var primitives = new List<Dictionary<string, object?>>();
foreach (var entity in geometry)
AddNormalizedPrimitives(entity, primitives);
var lines = primitives
.Where(item => string.Equals(ReadDictString(item, "kind"), "line", StringComparison.OrdinalIgnoreCase))
.Select(LinePrimitive.FromDictionary)
.Where(item => item != null && item.Length > 1e-9)
.Cast<LinePrimitive>()
.ToList();
var mergedLines = MergeStrongLines(lines);
var overlaps = FindStyleDifferentOverlaps(lines);
var summary = new Dictionary<string, object?>
{
["scope"] = scope,
["raw_geometry_count"] = geometry.Count,
["normalized_primitive_count"] = primitives.Count,
["normalized_line_count"] = lines.Count,
["merged_line_count"] = mergedLines.Count,
["overlap_group_count"] = overlaps.Count,
["angle_tolerance_deg"] = AngleToleranceDeg,
["collinear_distance_tolerance"] = CollinearDistanceTolerance,
["gap_tolerance"] = GapTolerance,
["merge_policy"] = "strong_only_same_style_collinear_interval_union"
};
return new NormalizedGeometrySet(primitives, mergedLines, overlaps, summary);
}
static void AddNormalizedPrimitives(Dictionary<string, object?> entity, List<Dictionary<string, object?>> output)
{
var objectName = ReadDictString(entity, "object_name");
if (IsPolyline(objectName))
{
AddPolylinePrimitives(entity, output);
return;
}
if (objectName.Contains("Circle", StringComparison.OrdinalIgnoreCase))
{
var center = ReadPoint(entity, "center");
var radius = ReadDictDouble(entity, "radius");
if (center.Count >= 2 && radius != null)
output.Add(BasePrimitive(entity, "circle", new Dictionary<string, object?>
{
["center"] = center,
["radius"] = radius,
["diameter"] = radius * 2
}));
return;
}
if (objectName.Contains("Arc", StringComparison.OrdinalIgnoreCase))
{
var center = ReadPoint(entity, "center");
var radius = ReadDictDouble(entity, "radius");
var startAngle = ReadDictDouble(entity, "start_angle");
var endAngle = ReadDictDouble(entity, "end_angle");
if (center.Count >= 2 && radius != null)
{
var extra = new Dictionary<string, object?>
{
["center"] = center,
["radius"] = radius,
["start_angle"] = startAngle,
["end_angle"] = endAngle
};
if (startAngle != null)
extra["start"] = PointOnCircle(center, radius.Value, startAngle.Value);
if (endAngle != null)
extra["end"] = PointOnCircle(center, radius.Value, endAngle.Value);
output.Add(BasePrimitive(entity, "arc", extra));
}
return;
}
if (objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase))
{
output.Add(BasePrimitive(entity, "spline", new Dictionary<string, object?>
{
["start"] = ReadPoint(entity, "start"),
["end"] = ReadPoint(entity, "end"),
["degree"] = ReadDictInt(entity, "degree"),
["closed"] = ReadDictBool(entity, "closed")
}));
return;
}
if (objectName.Contains("Line", StringComparison.OrdinalIgnoreCase))
{
AddLinePrimitive(entity, output, ReadPoint(entity, "start"), ReadPoint(entity, "end"), null, "line");
return;
}
if (objectName.Contains("Hatch", StringComparison.OrdinalIgnoreCase))
{
output.Add(BasePrimitive(entity, "hatch", new Dictionary<string, object?>
{
["pattern_name"] = ReadDictString(entity, "pattern_name"),
["pattern_scale"] = ReadDictDouble(entity, "pattern_scale")
}));
}
}
static void AddPolylinePrimitives(Dictionary<string, object?> entity, List<Dictionary<string, object?>> output)
{
var coordinates = ReadDoubleList(entity, "coordinates");
if (coordinates.Count < 4)
return;
var stride = coordinates.Count % 3 == 0 ? 3 : 2;
var points = new List<List<double>>();
for (var i = 0; i + stride - 1 < coordinates.Count; i += stride)
points.Add(new List<double> { coordinates[i], coordinates[i + 1] });
if (points.Count < 2)
return;
var bulges = ReadDoubleList(entity, "bulges");
var closed = ReadDictBool(entity, "closed") == true;
var segmentCount = closed ? points.Count : points.Count - 1;
for (var i = 0; i < segmentCount; i++)
{
var start = points[i];
var end = points[(i + 1) % points.Count];
var bulge = i < bulges.Count ? bulges[i] : 0.0;
if (Math.Abs(bulge) <= 1e-12)
{
AddLinePrimitive(entity, output, start, end, i + 1, "polyline_segment");
}
else
{
output.Add(BasePrimitive(entity, "arc", new Dictionary<string, object?>
{
["start"] = start,
["end"] = end,
["bulge"] = bulge,
["segment_index"] = i + 1,
["source_kind"] = "polyline_bulge_segment"
}));
}
}
}
static void AddLinePrimitive(
Dictionary<string, object?> entity,
List<Dictionary<string, object?>> output,
List<double> start,
List<double> end,
int? segmentIndex,
string sourceKind)
{
if (start.Count < 2 || end.Count < 2)
return;
var dx = end[0] - start[0];
var dy = end[1] - start[1];
var length = Math.Sqrt(dx * dx + dy * dy);
if (length <= 1e-9)
return;
var angle = NormalizeAngleDeg(Math.Atan2(dy, dx) * 180.0 / Math.PI);
var extra = new Dictionary<string, object?>
{
["start"] = start,
["end"] = end,
["length"] = length,
["angle_deg"] = angle,
["source_kind"] = sourceKind
};
if (segmentIndex != null)
extra["segment_index"] = segmentIndex;
output.Add(BasePrimitive(entity, "line", extra));
}
static List<Dictionary<string, object?>> MergeStrongLines(List<LinePrimitive> lines)
{
var result = new List<Dictionary<string, object?>>();
foreach (var styleGroup in lines.GroupBy(line => line.StyleKey, StringComparer.OrdinalIgnoreCase))
{
foreach (var angleGroup in styleGroup.GroupBy(line => AngleBucket(line.AngleDeg)))
{
foreach (var cluster in BuildCollinearClusters(angleGroup.ToList()))
{
foreach (var merged in MergeClusterIntervals(cluster))
result.Add(merged);
}
}
}
return result;
}
static List<Dictionary<string, object?>> FindStyleDifferentOverlaps(List<LinePrimitive> lines)
{
var result = new List<Dictionary<string, object?>>();
var groupIndex = 0;
foreach (var angleGroup in lines.GroupBy(line => AngleBucket(line.AngleDeg)))
{
foreach (var cluster in BuildCollinearClusters(angleGroup.ToList()))
{
var intervals = cluster
.Select(line => new LineInterval(line, Math.Min(line.T0, line.T1), Math.Max(line.T0, line.T1)))
.OrderBy(item => item.Start)
.ToList();
var active = new List<LineInterval>();
foreach (var current in intervals)
{
active.RemoveAll(item => item.End < current.Start + CollinearDistanceTolerance);
foreach (var prior in active)
{
if (string.Equals(prior.Line.StyleKey, current.Line.StyleKey, StringComparison.OrdinalIgnoreCase))
continue;
var overlapStart = Math.Max(prior.Start, current.Start);
var overlapEnd = Math.Min(prior.End, current.End);
var overlapLength = overlapEnd - overlapStart;
if (overlapLength <= GapTolerance)
continue;
groupIndex++;
result.Add(new Dictionary<string, object?>
{
["overlap_group_id"] = $"overlap-{groupIndex:000000}",
["handles"] = new[] { prior.Line.Handle, current.Line.Handle }.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(),
["indices"] = new[] { prior.Line.Index, current.Line.Index },
["overlap_length"] = overlapLength,
["reason"] = "same_geometry_different_style",
["style_a"] = prior.Line.Style,
["style_b"] = current.Line.Style
});
}
active.Add(current);
}
}
}
return result;
}
static List<List<LinePrimitive>> BuildCollinearClusters(List<LinePrimitive> lines)
{
var clusters = new List<List<LinePrimitive>>();
foreach (var line in lines.OrderBy(item => item.Offset))
{
List<LinePrimitive>? target = null;
foreach (var cluster in clusters)
{
if (IsCollinear(cluster[0], line))
{
target = cluster;
break;
}
}
if (target == null)
{
target = new List<LinePrimitive>();
clusters.Add(target);
}
target.Add(line);
}
return clusters;
}
static List<Dictionary<string, object?>> MergeClusterIntervals(List<LinePrimitive> cluster)
{
var result = new List<Dictionary<string, object?>>();
if (cluster.Count == 0)
return result;
var basis = cluster[0];
var intervals = cluster
.Select(line => new LineInterval(line, Math.Min(line.T0, line.T1), Math.Max(line.T0, line.T1)))
.OrderBy(item => item.Start)
.ToList();
var currentStart = intervals[0].Start;
var currentEnd = intervals[0].End;
var currentLines = new List<LinePrimitive> { intervals[0].Line };
void Flush()
{
var start = basis.PointAt(currentStart);
var end = basis.PointAt(currentEnd);
var rawLengthSum = currentLines.Sum(line => line.Length);
var coveredLength = Math.Max(0.0, currentEnd - currentStart);
result.Add(new Dictionary<string, object?>
{
["kind"] = "line",
["start"] = start,
["end"] = end,
["length"] = coveredLength,
["angle_deg"] = basis.AngleDeg,
["layer"] = basis.Layer,
["linetype"] = basis.Linetype,
["color"] = basis.Color,
["lineweight"] = basis.Lineweight,
["style_key"] = basis.StyleKey,
["source_handles"] = currentLines.SelectMany(line => line.SourceHandles).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(),
["source_indices"] = currentLines.Select(line => line.Index).Distinct().ToList(),
["merge_count"] = currentLines.Count,
["merge_type"] = "collinear_interval_union",
["covered_length"] = coveredLength,
["raw_length_sum"] = rawLengthSum,
["overlap_length"] = Math.Max(0.0, rawLengthSum - coveredLength)
});
}
foreach (var interval in intervals.Skip(1))
{
if (interval.Start <= currentEnd + GapTolerance)
{
currentEnd = Math.Max(currentEnd, interval.End);
currentLines.Add(interval.Line);
continue;
}
Flush();
currentStart = interval.Start;
currentEnd = interval.End;
currentLines = new List<LinePrimitive> { interval.Line };
}
Flush();
return result;
}
static Dictionary<string, object?> BasePrimitive(Dictionary<string, object?> entity, string kind, Dictionary<string, object?> extra)
{
var handle = ReadDictString(entity, "handle");
var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["kind"] = kind,
["space"] = ReadDictString(entity, "space"),
["index"] = ReadDictInt(entity, "index"),
["object_name"] = ReadDictString(entity, "object_name"),
["handle"] = handle,
["source_handles"] = string.IsNullOrWhiteSpace(handle) ? new List<string>() : new List<string> { handle },
["layer"] = ReadDictString(entity, "layer"),
["linetype"] = ReadDictString(entity, "linetype"),
["color"] = ReadDictInt(entity, "color"),
["lineweight"] = ReadDictInt(entity, "lineweight")
};
foreach (var item in extra)
result[item.Key] = item.Value;
result["style_key"] = StyleKey(result);
return result;
}
static bool IsCollinear(LinePrimitive a, LinePrimitive b)
{
if (Math.Abs(AngleDeltaDeg(a.AngleDeg, b.AngleDeg)) > AngleToleranceDeg)
return false;
return Math.Abs(a.SignedDistanceToLine(b.StartX, b.StartY)) <= CollinearDistanceTolerance &&
Math.Abs(a.SignedDistanceToLine(b.EndX, b.EndY)) <= CollinearDistanceTolerance;
}
static int AngleBucket(double angleDeg) => (int)Math.Round(NormalizeAngleDeg(angleDeg) / AngleToleranceDeg);
static double NormalizeAngleDeg(double angle)
{
angle %= 180.0;
if (angle < 0)
angle += 180.0;
return angle;
}
static double AngleDeltaDeg(double a, double b)
{
var delta = Math.Abs(NormalizeAngleDeg(a) - NormalizeAngleDeg(b));
return Math.Min(delta, 180.0 - delta);
}
static string StyleKey(Dictionary<string, object?> item)
{
return string.Join("|",
ReadDictString(item, "space"),
ReadDictString(item, "layer"),
ReadDictString(item, "linetype"),
ReadDictInt(item, "color")?.ToString(CultureInfo.InvariantCulture) ?? "",
ReadDictInt(item, "lineweight")?.ToString(CultureInfo.InvariantCulture) ?? "");
}
static List<double> PointOnCircle(List<double> center, double radius, double angleRad)
{
return new List<double>
{
center[0] + radius * Math.Cos(angleRad),
center[1] + radius * Math.Sin(angleRad)
};
}
static string ReadDictString(Dictionary<string, object?> item, string key)
{
return item.TryGetValue(key, out var value) ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? "" : "";
}
static int? ReadDictInt(Dictionary<string, object?> item, string key)
{
return item.TryGetValue(key, out var value) ? ToInt(value) : null;
}
static double? ReadDictDouble(Dictionary<string, object?> item, string key)
{
return item.TryGetValue(key, out var value) ? ToDouble(value) : null;
}
static bool? ReadDictBool(Dictionary<string, object?> item, string key)
{
return item.TryGetValue(key, out var value) ? ToBool(value) : null;
}
static List<double> ReadPoint(Dictionary<string, object?> item, string key) => ReadDoubleList(item, key);
static List<double> ReadDoubleList(Dictionary<string, object?> item, string key)
{
return item.TryGetValue(key, out var value) ? DoubleList(value) : new List<double>();
}
static void SelectMainView(DocumentExtraction extraction, string seedMode, string mainViewReferenceSelectionPath)
{
if (string.Equals(seedMode, "solidworks-candidate", StringComparison.OrdinalIgnoreCase))
SelectSolidWorksCandidateMainView(extraction);
else if (string.Equals(seedMode, "solidworks-multiview", StringComparison.OrdinalIgnoreCase))
SelectSolidWorksMultiviewMainView(extraction, mainViewReferenceSelectionPath);
else if (string.Equals(seedMode, "solidworks-multiview-inner-frame", StringComparison.OrdinalIgnoreCase))
SelectSolidWorksMultiviewInnerFrameMainView(extraction);
else
SelectUpperLeftMainView(extraction);
}
static void SelectUpperLeftMainView(DocumentExtraction extraction)
{
var rawNodes = BuildRawGeometryNodes(extraction.Geometry)
.Where(node => !string.Equals(node.Kind, "hatch", StringComparison.OrdinalIgnoreCase))
.ToList();
var drawingBox = BoundingBox.Union(rawNodes.Select(node => node.Box));
var nodes = rawNodes
.Where(node => !IsFrameOrBorderNode(node, drawingBox))
.ToList();
var lineSeedCandidates = nodes
.Where(node => string.Equals(node.Kind, "line", StringComparison.OrdinalIgnoreCase))
.ToList();
var seedCandidates = lineSeedCandidates.Where(IsPreferredMainViewSeed).ToList();
if (seedCandidates.Count == 0)
seedCandidates = lineSeedCandidates;
if (seedCandidates.Count == 0)
{
extraction.MainViewSelection = new Dictionary<string, object?>
{
["ok"] = false,
["reason"] = "no_raw_line_seed_available",
["selection_rule"] = "min_x_then_max_y_seed_line_connected_expansion"
};
return;
}
var seed = seedCandidates
.OrderBy(node => node.Box.MinX)
.ThenByDescending(node => node.Box.MaxY)
.First();
var selected = ExpandConnectedNodes(seed, nodes);
var selectedBox = BoundingBoxForViewBbox(selected, out _, out _);
// Include isolated holes/arcs/circles inside the seed-connected outline; they belong to
// the same view but may not touch the contour geometrically.
foreach (var node in nodes)
{
if (selected.Contains(node))
continue;
if (selectedBox.Expand(MainViewConnectTolerance).Contains(node.Box.CenterX, node.Box.CenterY))
selected.Add(node);
}
selectedBox = BoundingBoxForViewBbox(
selected,
out var bboxExcludedCenterlineNodeCount,
out var bboxExcludedSectionCutSymbolNodeCount);
var selectedHandles = selected
.SelectMany(node => node.SourceHandles)
.Where(handle => !string.IsNullOrWhiteSpace(handle))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
extraction.MainViewGeometry = extraction.Geometry
.Where(item => selectedHandles.Contains(ReadDictString(item, "handle")))
.ToList();
extraction.MainViewSelection = new Dictionary<string, object?>
{
["ok"] = true,
["selection_rule"] = "min_x_then_max_y_seed_line_connected_expansion",
["processing_order"] = "raw_geometry_seed_expand_then_main_view_normalization_merge_overlap",
["seed"] = new Dictionary<string, object?>
{
["bbox"] = seed.Box.ToDictionary(),
["source_handles"] = seed.SourceHandles,
["object_name"] = ReadDictString(seed.Data, "object_name"),
["layer"] = ReadDictString(seed.Data, "layer"),
["linetype"] = ReadDictString(seed.Data, "linetype"),
["seed_filter"] = "preferred_visible_contour_line_excluding_frame"
},
["bbox"] = selectedBox.ToDictionary(),
["bbox_policy"] = "exclude_centerline_and_section_cut_symbol_nodes_keep_geometry",
["bbox_excluded_centerline_node_count"] = bboxExcludedCenterlineNodeCount,
["bbox_excluded_section_cut_symbol_node_count"] = bboxExcludedSectionCutSymbolNodeCount,
["bbox_excluded_auxiliary_node_count"] = bboxExcludedCenterlineNodeCount + bboxExcludedSectionCutSymbolNodeCount,
["drawing_bbox"] = drawingBox.ToDictionary(),
["connect_tolerance"] = MainViewConnectTolerance,
["selected_node_count"] = selected.Count,
["selected_source_handle_count"] = selectedHandles.Count,
["main_view_geometry_count"] = extraction.MainViewGeometry.Count,
["excluded"] = "annotations_dimensions_text_hatch_frame_border"
};
}
static void SelectSolidWorksCandidateMainView(DocumentExtraction extraction)
{
var rawNodes = BuildRawGeometryNodes(extraction.Geometry)
.Where(node => !string.Equals(node.Kind, "hatch", StringComparison.OrdinalIgnoreCase))
.ToList();
var drawingBox = BoundingBox.Union(rawNodes.Select(node => node.Box));
var frameSeeds = rawNodes
.Where(node => IsOuterDrawingEdgeNode(node, drawingBox))
.OrderByDescending(node => Math.Max(node.Box.Width, node.Box.Height))
.ToList();
var frameAndTitleNodes = new HashSet<PrimitiveNode>();
foreach (var seed in frameSeeds)
frameAndTitleNodes.UnionWith(ExpandConnectedNodes(seed, rawNodes));
var selected = rawNodes
.Where(node => !frameAndTitleNodes.Contains(node))
.ToHashSet();
if (selected.Count == 0)
{
extraction.MainViewSelection = new Dictionary<string, object?>
{
["ok"] = false,
["reason"] = "no_geometry_after_frame_title_filter",
["selection_rule"] = "solidworks_candidate_remove_frame_title_take_remaining_geometry"
};
return;
}
var selectedBox = BoundingBox.Union(selected.Select(node => node.Box));
var internalNodesBeforeAbsorb = rawNodes
.Where(node => !selected.Contains(node) &&
selectedBox.Expand(MainViewConnectTolerance).ContainsBox(node.Box))
.ToList();
foreach (var node in internalNodesBeforeAbsorb)
selected.Add(node);
selectedBox = BoundingBox.Union(selected.Select(node => node.Box));
var internalNodesAfterAbsorb = rawNodes
.Where(node => !selected.Contains(node) &&
selectedBox.Expand(MainViewConnectTolerance).ContainsBox(node.Box))
.ToList();
var selectedHandles = selected
.SelectMany(node => node.SourceHandles)
.Where(handle => !string.IsNullOrWhiteSpace(handle))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
extraction.MainViewGeometry = extraction.Geometry
.Where(item => selectedHandles.Contains(ReadDictString(item, "handle")))
.ToList();
extraction.MainViewSelection = new Dictionary<string, object?>
{
["ok"] = true,
["selection_rule"] = "solidworks_candidate_remove_frame_title_take_remaining_geometry",
["processing_order"] = "raw_geometry_expand_outer_edge_connected_frame_title_then_take_remaining_geometry",
["bbox"] = selectedBox.ToDictionary(),
["drawing_bbox"] = drawingBox.ToDictionary(),
["frame_seed_count"] = frameSeeds.Count,
["frame_title_node_count"] = frameAndTitleNodes.Count,
["bbox_internal_absorbed_node_count"] = internalNodesBeforeAbsorb.Count,
["bbox_internal_missing_node_count"] = internalNodesAfterAbsorb.Count,
["bbox_internal_missing_handles"] = internalNodesAfterAbsorb
.SelectMany(node => node.SourceHandles)
.Where(handle => !string.IsNullOrWhiteSpace(handle))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(),
["selected_node_count"] = selected.Count,
["selected_source_handle_count"] = selectedHandles.Count,
["main_view_geometry_count"] = extraction.MainViewGeometry.Count,
["excluded"] = "annotations_dimensions_text_hatch_frame_border_solidworks_title_block"
};
}
static void SelectSolidWorksMultiviewMainView(DocumentExtraction extraction, string mainViewReferenceSelectionPath)
{
var rawNodes = BuildRawGeometryNodes(extraction.Geometry)
.Where(node => !string.Equals(node.Kind, "hatch", StringComparison.OrdinalIgnoreCase))
.ToList();
var drawingBox = BoundingBox.Union(rawNodes.Select(node => node.Box));
var frameSeeds = rawNodes
.Where(node => IsOuterDrawingEdgeNode(node, drawingBox))
.OrderByDescending(node => Math.Max(node.Box.Width, node.Box.Height))
.ToList();
var frameAndTitleNodes = new HashSet<PrimitiveNode>();
foreach (var seed in frameSeeds)
frameAndTitleNodes.UnionWith(ExpandConnectedNodes(seed, rawNodes));
var remaining = rawNodes
.Where(node => !frameAndTitleNodes.Contains(node))
.ToList();
if (remaining.Count == 0)
{
extraction.MainViewSelection = new Dictionary<string, object?>
{
["ok"] = false,
["reason"] = "no_geometry_after_frame_title_filter",
["selection_rule"] = "solidworks_multiview_remove_frame_title_then_pick_upper_left_component"
};
return;
}
if (TrySelectSolidWorksMultiviewMainViewByReferenceBox(
extraction,
remaining,
drawingBox,
frameSeeds.Count,
frameAndTitleNodes.Count,
mainViewReferenceSelectionPath))
{
return;
}
var components = new List<HashSet<PrimitiveNode>>();
var unassigned = new HashSet<PrimitiveNode>(remaining);
while (unassigned.Count > 0)
{
var seed = unassigned
.OrderBy(node => node.Box.MinX)
.ThenByDescending(node => node.Box.MaxY)
.First();
var component = ExpandConnectedNodes(seed, remaining);
components.Add(component);
unassigned.ExceptWith(component);
}
var selected = components
.Select(component => new
{
Nodes = component,
Box = BoundingBox.Union(component.Select(node => node.Box))
})
.OrderBy(item => item.Box.MinX)
.ThenByDescending(item => item.Box.MaxY)
.First();
var selectedNodes = selected.Nodes;
var selectedBox = selected.Box;
var internalNodes = remaining
.Where(node => !selectedNodes.Contains(node) &&
selectedBox.Expand(MainViewConnectTolerance).Contains(node.Box.CenterX, node.Box.CenterY))
.ToList();
foreach (var node in internalNodes)
selectedNodes.Add(node);
selectedBox = BoundingBox.Union(selectedNodes.Select(node => node.Box));
var selectedHandles = selectedNodes
.SelectMany(node => node.SourceHandles)
.Where(handle => !string.IsNullOrWhiteSpace(handle))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
extraction.MainViewGeometry = extraction.Geometry
.Where(item => selectedHandles.Contains(ReadDictString(item, "handle")))
.ToList();
extraction.MainViewSelection = new Dictionary<string, object?>
{
["ok"] = true,
["selection_rule"] = "solidworks_multiview_remove_frame_title_then_pick_upper_left_component",
["processing_order"] = "remove_outer_edge_connected_frame_title_then_connected_components_pick_min_x_max_y",
["bbox"] = selectedBox.ToDictionary(),
["drawing_bbox"] = drawingBox.ToDictionary(),
["frame_seed_count"] = frameSeeds.Count,
["frame_title_node_count"] = frameAndTitleNodes.Count,
["component_count"] = components.Count,
["bbox_internal_absorbed_node_count"] = internalNodes.Count,
["selected_node_count"] = selectedNodes.Count,
["selected_source_handle_count"] = selectedHandles.Count,
["main_view_geometry_count"] = extraction.MainViewGeometry.Count,
["excluded"] = "annotations_dimensions_text_hatch_frame_border_solidworks_title_block_other_projected_views"
};
}
static bool TrySelectSolidWorksMultiviewMainViewByReferenceBox(
DocumentExtraction extraction,
List<PrimitiveNode> remaining,
BoundingBox drawingBox,
int frameSeedCount,
int frameTitleNodeCount,
string mainViewReferenceSelectionPath)
{
if (string.IsNullOrWhiteSpace(mainViewReferenceSelectionPath))
return false;
if (!File.Exists(mainViewReferenceSelectionPath))
throw new FileNotFoundException($"main view reference selection 不存在: {mainViewReferenceSelectionPath}");
var referenceBox = ReadReferenceSelectionBox(mainViewReferenceSelectionPath);
if (referenceBox == null)
throw new InvalidOperationException($"main view reference selection 缺少 bbox: {mainViewReferenceSelectionPath}");
var expandedReferenceBox = referenceBox.Value.Expand(Math.Max(MainViewConnectTolerance, 1.0));
var selectedNodes = remaining
.Where(node => expandedReferenceBox.ContainsBox(node.Box) ||
expandedReferenceBox.Contains(node.Box.CenterX, node.Box.CenterY) ||
BoxesOverlap(expandedReferenceBox, node.Box, MainViewConnectTolerance))
.ToHashSet();
if (selectedNodes.Count == 0)
return false;
var selectedBox = BoundingBox.Union(selectedNodes.Select(node => node.Box));
var absorbedNodes = remaining
.Where(node => !selectedNodes.Contains(node) &&
selectedBox.Expand(MainViewConnectTolerance).ContainsBox(node.Box))
.ToList();
foreach (var node in absorbedNodes)
selectedNodes.Add(node);
selectedBox = BoundingBox.Union(selectedNodes.Select(node => node.Box));
var selectedHandles = selectedNodes
.SelectMany(node => node.SourceHandles)
.Where(handle => !string.IsNullOrWhiteSpace(handle))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
extraction.MainViewGeometry = extraction.Geometry
.Where(item => selectedHandles.Contains(ReadDictString(item, "handle")))
.ToList();
extraction.MainViewSelection = new Dictionary<string, object?>
{
["ok"] = true,
["selection_rule"] = "solidworks_multiview_reference_main_bbox",
["processing_order"] = "remove_outer_edge_connected_frame_title_then_select_geometry_inside_previous_main_view_bbox",
["bbox"] = selectedBox.ToDictionary(),
["reference_bbox"] = referenceBox.Value.ToDictionary(),
["reference_selection_path"] = mainViewReferenceSelectionPath,
["drawing_bbox"] = drawingBox.ToDictionary(),
["frame_seed_count"] = frameSeedCount,
["frame_title_node_count"] = frameTitleNodeCount,
["bbox_internal_absorbed_node_count"] = absorbedNodes.Count,
["selected_node_count"] = selectedNodes.Count,
["selected_source_handle_count"] = selectedHandles.Count,
["main_view_geometry_count"] = extraction.MainViewGeometry.Count,
["excluded"] = "annotations_dimensions_text_hatch_frame_border_solidworks_title_block_other_projected_views"
};
return true;
}
static void SelectSolidWorksMultiviewInnerFrameMainView(DocumentExtraction extraction)
{
var rawNodes = BuildRawGeometryNodes(extraction.Geometry)
.Where(node => !string.Equals(node.Kind, "hatch", StringComparison.OrdinalIgnoreCase))
.ToList();
var drawingBox = BoundingBox.Union(rawNodes.Select(node => node.Box));
if (!TryFindInnerFrameAnchors(rawNodes, drawingBox, out var leftFrame, out var topFrame))
{
extraction.MainViewSelection = new Dictionary<string, object?>
{
["ok"] = false,
["reason"] = "inner_frame_left_or_top_line_not_found",
["selection_rule"] = "solidworks_multiview_inner_frame_seed_spatial_expansion",
["drawing_bbox"] = drawingBox.ToDictionary()
};
return;
}
var frameSeeds = rawNodes
.Where(node => IsOuterDrawingEdgeNode(node, drawingBox))
.OrderByDescending(node => Math.Max(node.Box.Width, node.Box.Height))
.ToList();
var frameAndTitleNodes = new HashSet<PrimitiveNode>();
foreach (var seed in frameSeeds)
frameAndTitleNodes.UnionWith(ExpandConnectedNodes(seed, rawNodes));
var remaining = rawNodes
.Where(node => !frameAndTitleNodes.Contains(node))
.ToList();
var innerTolerance = Math.Max(MainViewConnectTolerance, Math.Min(drawingBox.Width, drawingBox.Height) * 0.001);
var seedCandidates = remaining
.Where(node => string.Equals(node.Kind, "line", StringComparison.OrdinalIgnoreCase))
.Where(node => node.Box.MinX > leftFrame!.Box.MinX + innerTolerance)
.Where(node => node.Box.MaxY < topFrame!.Box.MaxY - innerTolerance)
.ToList();
var preferredSeedCandidates = seedCandidates.Where(IsPreferredMainViewSeed).ToList();
if (preferredSeedCandidates.Count > 0)
seedCandidates = preferredSeedCandidates;
if (seedCandidates.Count == 0)
{
extraction.MainViewSelection = new Dictionary<string, object?>
{
["ok"] = false,
["reason"] = "no_seed_line_inside_inner_frame",
["selection_rule"] = "solidworks_multiview_inner_frame_seed_spatial_expansion",
["drawing_bbox"] = drawingBox.ToDictionary(),
["inner_frame_left"] = NodeToDictionary(leftFrame!),
["inner_frame_top"] = NodeToDictionary(topFrame!),
["frame_seed_count"] = frameSeeds.Count,
["frame_title_node_count"] = frameAndTitleNodes.Count
};
return;
}
var seedLine = seedCandidates
.OrderBy(node => node.Box.MinX)
.ThenByDescending(node => node.Box.MaxY)
.ThenByDescending(node => Math.Max(node.Box.Width, node.Box.Height))
.First();
var selectedRegion = ExpandRegionFromSeed(
seedLine,
remaining,
new HashSet<string>(StringComparer.OrdinalIgnoreCase));
var selectedNodes = selectedRegion.Nodes;
var selectedBox = BoundingBoxForViewBbox(selectedNodes, out _, out _);
var changed = true;
var absorbedNodeCount = 0;
while (changed)
{
changed = false;
var expandedSelectedBox = selectedBox.Expand(MainViewConnectTolerance);
var internalNodes = remaining
.Where(node => !selectedNodes.Contains(node))
.Where(node => expandedSelectedBox.ContainsBox(node.Box) ||
expandedSelectedBox.Contains(node.Box.CenterX, node.Box.CenterY))
.ToList();
foreach (var node in internalNodes)
selectedNodes.Add(node);
if (internalNodes.Count == 0)
continue;
absorbedNodeCount += internalNodes.Count;
selectedBox = BoundingBoxForViewBbox(selectedNodes, out _, out _);
changed = true;
}
selectedBox = BoundingBoxForViewBbox(
selectedNodes,
out var bboxExcludedCenterlineNodeCount,
out var bboxExcludedSectionCutSymbolNodeCount);
var selectedHandles = selectedNodes
.SelectMany(node => node.SourceHandles)
.Where(handle => !string.IsNullOrWhiteSpace(handle))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
extraction.MainViewGeometry = extraction.Geometry
.Where(item => selectedHandles.Contains(ReadDictString(item, "handle")))
.ToList();
extraction.MainViewSelection = new Dictionary<string, object?>
{
["ok"] = true,
["selection_rule"] = "solidworks_multiview_inner_frame_seed_spatial_expansion",
["processing_order"] = "find_inner_left_top_frame_lines_then_pick_min_inner_x_max_y_seed_and_expand_spatial_view_region",
["bbox"] = selectedBox.ToDictionary(),
["bbox_policy"] = "exclude_centerline_and_section_cut_symbol_nodes_keep_geometry",
["bbox_excluded_centerline_node_count"] = bboxExcludedCenterlineNodeCount,
["bbox_excluded_section_cut_symbol_node_count"] = bboxExcludedSectionCutSymbolNodeCount,
["bbox_excluded_auxiliary_node_count"] = bboxExcludedCenterlineNodeCount + bboxExcludedSectionCutSymbolNodeCount,
["drawing_bbox"] = drawingBox.ToDictionary(),
["inner_frame_left"] = NodeToDictionary(leftFrame!),
["inner_frame_top"] = NodeToDictionary(topFrame!),
["seed"] = NodeToDictionary(seedLine),
["seed_candidate_count"] = seedCandidates.Count,
["frame_seed_count"] = frameSeeds.Count,
["frame_title_node_count"] = frameAndTitleNodes.Count,
["bbox_internal_absorbed_node_count"] = absorbedNodeCount,
["selected_node_count"] = selectedNodes.Count,
["selected_source_handle_count"] = selectedHandles.Count,
["main_view_geometry_count"] = extraction.MainViewGeometry.Count,
["excluded"] = "annotations_dimensions_text_hatch_frame_border_solidworks_title_block_other_projected_views"
};
}
static bool TryFindInnerFrameAnchors(
List<PrimitiveNode> rawNodes,
BoundingBox drawingBox,
out PrimitiveNode? leftFrame,
out PrimitiveNode? topFrame)
{
var tolerance = Math.Max(MainViewConnectTolerance, Math.Min(drawingBox.Width, drawingBox.Height) * 0.001);
var verticalMinHeight = drawingBox.Height * 0.45;
var horizontalMinWidth = drawingBox.Width * 0.45;
leftFrame = rawNodes
.Where(node => IsVerticalLineNode(node, tolerance))
.Where(node => node.Box.Height >= verticalMinHeight)
.Where(node => node.Box.MinX > drawingBox.MinX + tolerance)
.Where(node => node.Box.CenterX < drawingBox.CenterX)
.OrderByDescending(node => node.Box.Height)
.ThenBy(node => node.Box.MinX)
.FirstOrDefault();
topFrame = rawNodes
.Where(node => IsHorizontalLineNode(node, tolerance))
.Where(node => node.Box.Width >= horizontalMinWidth)
.Where(node => node.Box.MaxY < drawingBox.MaxY - tolerance)
.Where(node => node.Box.CenterY > drawingBox.CenterY)
.OrderByDescending(node => node.Box.Width)
.ThenByDescending(node => node.Box.MaxY)
.FirstOrDefault();
return leftFrame != null && topFrame != null;
}
static BoundingBox? ReadReferenceSelectionBox(string path)
{
using var document = JsonDocument.Parse(File.ReadAllText(path));
if (!document.RootElement.TryGetProperty("bbox", out var bbox) ||
bbox.ValueKind != JsonValueKind.Object)
return null;
if (!TryGetJsonDouble(bbox, "min_x", out var minX) ||
!TryGetJsonDouble(bbox, "min_y", out var minY) ||
!TryGetJsonDouble(bbox, "max_x", out var maxX) ||
!TryGetJsonDouble(bbox, "max_y", out var maxY))
{
return null;
}
return new BoundingBox(minX, minY, maxX, maxY);
}
static bool TryGetJsonDouble(JsonElement element, string propertyName, out double value)
{
value = 0;
return element.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.Number &&
property.TryGetDouble(out value);
}
static void AnalyzeViewRegions(DocumentExtraction extraction, CliOptions options)
{
var rawNodes = BuildRawGeometryNodes(extraction.Geometry)
.Where(node => !string.Equals(node.Kind, "hatch", StringComparison.OrdinalIgnoreCase))
.ToList();
if (rawNodes.Count == 0)
{
extraction.ViewRegions = new Dictionary<string, object?>
{
["ok"] = false,
["reason"] = "no_raw_geometry_nodes"
};
return;
}
var drawingBox = BoundingBox.Union(rawNodes.Select(node => node.Box));
var nodes = rawNodes
.Where(node => !IsFrameOrBorderNode(node, drawingBox))
.ToList();
var mainBox = ReadEntityBox(extraction.MainViewSelection, "bbox");
if (mainBox == null)
{
extraction.ViewRegions = new Dictionary<string, object?>
{
["ok"] = false,
["reason"] = "main_view_bbox_missing",
["drawing_bbox"] = drawingBox.ToDictionary()
};
return;
}
var mainHandles = extraction.MainViewGeometry
.Select(item => ReadDictString(item, "handle"))
.Where(handle => !string.IsNullOrWhiteSpace(handle))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var sectionSymbols = FindSectionSymbols(extraction.Annotations)
.OrderByDescending(symbol => symbol.Box.CenterY)
.ThenBy(symbol => symbol.Box.CenterX)
.ToList();
var sectionViews = new List<Dictionary<string, object?>>();
var sectionHandles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var symbol in sectionSymbols)
{
var seed = FindSectionSeed(symbol, nodes, mainHandles, sectionHandles, options.SectionViewProbeDirection);
if (seed == null)
{
sectionViews.Add(new Dictionary<string, object?>
{
["ok"] = false,
["reason"] = $"no_geometry_seed_{options.SectionViewProbeDirection}_section_symbol",
["probe_direction"] = options.SectionViewProbeDirection,
["label"] = symbol.Label,
["symbol"] = symbol.ToDictionary()
});
continue;
}
var excluded = new HashSet<string>(mainHandles, StringComparer.OrdinalIgnoreCase);
excluded.UnionWith(sectionHandles);
var region = ExpandRegionFromSeed(seed, nodes, excluded);
if (sectionViews.Any(item =>
ReadEntityBox(item, "bbox") is { } existing &&
BoxesOverlap(existing, region.Box, MainViewConnectTolerance)))
{
continue;
}
foreach (var handle in region.Handles)
sectionHandles.Add(handle);
sectionViews.Add(new Dictionary<string, object?>
{
["ok"] = true,
["kind"] = "section_view",
["label"] = symbol.Label,
["probe_direction"] = options.SectionViewProbeDirection,
["symbol"] = symbol.ToDictionary(),
["seed"] = NodeToDictionary(seed),
["bbox"] = region.Box.ToDictionary(),
["geometry_node_count"] = region.Nodes.Count,
["source_handle_count"] = region.Handles.Count,
["source_handles"] = region.Handles.OrderBy(handle => handle, StringComparer.OrdinalIgnoreCase).ToList()
});
}
var orthographicViews = new List<Dictionary<string, object?>>();
orthographicViews.Add(ProbeOrthographicView(
"down",
"bottom_view_candidate",
mainBox.Value,
nodes,
mainHandles,
sectionHandles));
orthographicViews.Add(ProbeOrthographicView(
"right",
"right_of_main_view_candidate",
mainBox.Value,
nodes,
mainHandles,
sectionHandles));
foreach (var sectionView in sectionViews)
AttachRegionSignature(sectionView, extraction.Geometry, ReadDictString(sectionView, "kind"));
foreach (var orthographicView in orthographicViews)
AttachRegionSignature(orthographicView, extraction.Geometry, ReadDictString(orthographicView, "kind"));
var bottomBox = orthographicViews
.Where(item => string.Equals(ReadDictString(item, "kind"), "bottom_view_candidate", StringComparison.OrdinalIgnoreCase))
.Where(item => item.TryGetValue("ok", out var ok) && ok is true)
.Select(item => ReadEntityBox(item, "bbox"))
.FirstOrDefault(item => item != null);
extraction.SectionCutSymbols = FindSectionCutSymbols(extraction.Annotations, mainBox.Value, bottomBox);
extraction.BottomViewSignature = orthographicViews
.Where(item => string.Equals(ReadDictString(item, "kind"), "bottom_view_candidate", StringComparison.OrdinalIgnoreCase))
.Where(item => item.TryGetValue("ok", out var ok) && ok is true)
.Select(item => item.TryGetValue("signature", out var signature) && signature is Dictionary<string, object?> dict ? dict : null)
.FirstOrDefault(item => item != null)
?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
extraction.ViewRegions = new Dictionary<string, object?>
{
["ok"] = true,
["rule"] = $"section_symbols_expand_{options.SectionViewProbeDirection}_then_probe_orthographic_down_right_from_main_bbox_center",
["section_view_probe_direction"] = options.SectionViewProbeDirection,
["main_view_bbox"] = mainBox.Value.ToDictionary(),
["drawing_bbox"] = drawingBox.ToDictionary(),
["section_symbol_count"] = sectionSymbols.Count,
["section_view_count"] = sectionViews.Count(item => item.TryGetValue("ok", out var ok) && ok is true),
["section_views"] = sectionViews,
["section_cut_symbol_count"] = extraction.SectionCutSymbols.Count,
["section_cut_symbols"] = extraction.SectionCutSymbols,
["orthographic_probe_directions"] = new[] { "down", "right" },
["orthographic_views"] = orthographicViews,
["excluded_from_orthographic_probe"] = "main_view_handles_and_section_view_handles"
};
}
static List<Dictionary<string, object?>> FindSectionCutSymbols(
List<Dictionary<string, object?>> annotations,
BoundingBox mainBox,
BoundingBox? bottomBox)
{
var result = new List<Dictionary<string, object?>>();
foreach (var block in annotations)
{
if (!block.TryGetValue("exploded_entities", out var explodedValue) ||
explodedValue is not IEnumerable<object> explodedObjects)
{
continue;
}
var children = explodedObjects
.OfType<Dictionary<string, object?>>()
.ToList();
if (children.Count == 0)
continue;
var labels = children
.Select(child => new
{
Text = ExtractSingleLetterLabel(ReadDictString(child, "text")),
Box = ReadEntityBox(child, "bbox"),
Child = child
})
.Where(item => item.Text != null && item.Box != null)
.ToList();
if (labels.Count != 2 || !string.Equals(labels[0].Text, labels[1].Text, StringComparison.OrdinalIgnoreCase))
continue;
var lines = children
.Where(child => string.Equals(ReadDictString(child, "object_name"), "AcDbLine", StringComparison.OrdinalIgnoreCase))
.ToList();
var hatches = children
.Where(child => string.Equals(ReadDictString(child, "object_name"), "AcDbHatch", StringComparison.OrdinalIgnoreCase))
.Select(child => new { Box = ReadEntityBox(child, "bbox"), Child = child })
.Where(item => item.Box != null)
.ToList();
if (lines.Count < 4 || hatches.Count < 2)
continue;
var blockBox = ReadEntityBox(block, "bbox") ?? BoundingBox.Union(children.Select(child => ReadEntityBox(child, "bbox")).Where(box => box != null).Select(box => box!.Value));
var markers = hatches
.OrderByDescending(item => item.Box!.Value.CenterY)
.Select(item => BuildSectionCutMarker(item.Box!.Value, lines, labels.Select(label => label.Box!.Value).ToList()))
.Where(item => item != null)
.Cast<Dictionary<string, object?>>()
.ToList();
if (markers.Count < 2)
continue;
var corners = markers
.Select(marker => ReadPoint(marker, "corner"))
.Where(point => point.Count >= 2)
.ToList();
if (corners.Count < 2)
continue;
var cornerPair = FindFarthestPointPair(corners);
var rawStart = cornerPair.Start;
var rawEnd = cornerPair.End;
var cutDirection = Math.Abs(rawStart[0] - rawEnd[0]) <= Math.Abs(rawStart[1] - rawEnd[1])
? "vertical"
: "horizontal";
var cornerStart = cutDirection == "vertical"
? new[] { rawStart, rawEnd }.OrderByDescending(point => point[1]).ThenBy(point => point[0]).First()
: new[] { rawStart, rawEnd }.OrderBy(point => point[0]).ThenByDescending(point => point[1]).First();
var cornerEnd = cutDirection == "vertical"
? new[] { rawStart, rawEnd }.OrderBy(point => point[1]).ThenBy(point => point[0]).First()
: new[] { rawStart, rawEnd }.OrderByDescending(point => point[0]).ThenByDescending(point => point[1]).First();
var cutCenter = new List<double>
{
(cornerStart[0] + cornerEnd[0]) / 2.0,
(cornerStart[1] + cornerEnd[1]) / 2.0
};
var cornerDistance = Distance(cornerStart, cornerEnd);
var halfLength = Math.Max(cornerDistance / 2.0, 1.0);
var cutLineStart = cutDirection == "vertical"
? new List<double> { cutCenter[0], cutCenter[1] + halfLength }
: new List<double> { cutCenter[0] - halfLength, cutCenter[1] };
var cutLineEnd = cutDirection == "vertical"
? new List<double> { cutCenter[0], cutCenter[1] - halfLength }
: new List<double> { cutCenter[0] + halfLength, cutCenter[1] };
var viewKind = ClassifySectionCutView(blockBox, mainBox, bottomBox);
result.Add(new Dictionary<string, object?>
{
["ok"] = true,
["kind"] = "section_cut_symbol_pair",
["label"] = labels[0].Text,
["source"] = "block_reference_exploded_entities",
["parent_handle"] = ReadDictString(block, "handle"),
["block_name"] = ReadDictString(block, "name"),
["effective_name"] = ReadDictString(block, "effective_name"),
["view_kind"] = viewKind,
["bbox"] = blockBox.ToDictionary(),
["text_count"] = labels.Count,
["line_count"] = lines.Count,
["arrow_hatch_count"] = hatches.Count,
["cut_line"] = new Dictionary<string, object?>
{
["start"] = cutLineStart,
["end"] = cutLineEnd,
["center"] = cutCenter,
["direction"] = cutDirection,
["source"] = "paired_section_symbol_corners_common_center",
["corner_start"] = cornerStart,
["corner_end"] = cornerEnd,
["corner_count"] = corners.Count
},
["markers"] = markers
});
}
return result
.OrderBy(item => ReadDictString(item, "label"), StringComparer.OrdinalIgnoreCase)
.ThenBy(item => ReadEntityBox(item, "bbox")?.CenterX ?? 0)
.ThenByDescending(item => ReadEntityBox(item, "bbox")?.CenterY ?? 0)
.ToList();
}
static Dictionary<string, object?>? BuildSectionCutMarker(
BoundingBox arrowBox,
List<Dictionary<string, object?>> lines,
List<BoundingBox> labelBoxes)
{
var candidates = new List<(Dictionary<string, object?> Marker, double Score)>();
var horizontal = BuildSectionCutMarkerForLeaderAxis(arrowBox, lines, labelBoxes, leaderIsHorizontal: true);
if (horizontal != null)
candidates.Add(horizontal.Value);
var vertical = BuildSectionCutMarkerForLeaderAxis(arrowBox, lines, labelBoxes, leaderIsHorizontal: false);
if (vertical != null)
candidates.Add(vertical.Value);
return candidates
.OrderBy(candidate => candidate.Score)
.Select(candidate => candidate.Marker)
.FirstOrDefault();
}
static (Dictionary<string, object?> Marker, double Score)? BuildSectionCutMarkerForLeaderAxis(
BoundingBox arrowBox,
List<Dictionary<string, object?>> lines,
List<BoundingBox> labelBoxes,
bool leaderIsHorizontal)
{
var arrowCenter = new List<double> { arrowBox.CenterX, arrowBox.CenterY };
var lineItems = lines
.Select(line => new { Line = line, Start = ReadPoint(line, "start"), End = ReadPoint(line, "end"), Box = ReadEntityBox(line, "bbox") })
.Where(item => item.Start.Count >= 2 && item.End.Count >= 2 && item.Box != null)
.ToList();
var leaders = lineItems
.Where(item => leaderIsHorizontal
? Math.Abs(item.Start[1] - item.End[1]) <= 1e-6
: Math.Abs(item.Start[0] - item.End[0]) <= 1e-6)
.OrderBy(item => leaderIsHorizontal
? Math.Abs(item.Box!.Value.CenterY - arrowBox.CenterY)
: Math.Abs(item.Box!.Value.CenterX - arrowBox.CenterX))
.ThenBy(item => item.Box!.Value.DistanceTo(arrowBox))
.FirstOrDefault();
if (leaders == null)
return null;
var endpoints = new[] { leaders.Start, leaders.End };
var sectionSymbolLines = lineItems
.Where(item => leaderIsHorizontal
? Math.Abs(item.Start[0] - item.End[0]) <= 1e-6
: Math.Abs(item.Start[1] - item.End[1]) <= 1e-6)
.ToList();
var cornerCandidate = endpoints
.Select(point => new
{
Point = point,
SectionSymbolLine = sectionSymbolLines
.OrderBy(line => new[] { line.Start, line.End }.Min(vp => Distance(point, vp)))
.FirstOrDefault(),
DistanceToSectionSymbol = sectionSymbolLines
.SelectMany(line => new[] { line.Start, line.End })
.Select(vp => Distance(point, vp))
.DefaultIfEmpty(double.MaxValue)
.Min()
})
.OrderBy(item => item.DistanceToSectionSymbol)
.First();
var corner = cornerCandidate.Point;
var sectionSymbolLine = cornerCandidate.SectionSymbolLine;
if (sectionSymbolLine == null)
return null;
var dx = arrowCenter[0] - corner[0];
var dy = arrowCenter[1] - corner[1];
var length = Math.Sqrt(dx * dx + dy * dy);
var labelBox = labelBoxes
.OrderBy(box => box.DistanceTo(arrowBox))
.FirstOrDefault();
var marker = new Dictionary<string, object?>
{
["corner"] = corner,
["arrow_tip"] = arrowCenter,
["arrow_direction"] = length > 1e-9
? new[] { dx / length, dy / length }
: new[] { 0.0, 0.0 },
[leaderIsHorizontal ? "horizontal_line" : "vertical_line"] = new Dictionary<string, object?>
{
["start"] = leaders.Start,
["end"] = leaders.End
},
["section_symbol_line"] = new Dictionary<string, object?>
{
["start"] = sectionSymbolLine.Start,
["end"] = sectionSymbolLine.End
},
["label_bbox"] = labelBox.ToDictionary()
};
var score = (leaderIsHorizontal
? Math.Abs(leaders.Box!.Value.CenterY - arrowBox.CenterY)
: Math.Abs(leaders.Box!.Value.CenterX - arrowBox.CenterX)) +
leaders.Box!.Value.DistanceTo(arrowBox) +
cornerCandidate.DistanceToSectionSymbol;
return (marker, score);
}
static (List<double> Start, List<double> End) FindFarthestPointPair(List<List<double>> points)
{
var bestStart = points[0];
var bestEnd = points[1];
var bestDistance = Distance(bestStart, bestEnd);
for (var i = 0; i < points.Count; i++)
{
for (var j = i + 1; j < points.Count; j++)
{
var distance = Distance(points[i], points[j]);
if (distance > bestDistance)
{
bestStart = points[i];
bestEnd = points[j];
bestDistance = distance;
}
}
}
return (bestStart, bestEnd);
}
static (List<double> Start, List<double> End)? ReadLine(Dictionary<string, object?> dict, string key)
{
if (!dict.TryGetValue(key, out var value) || value is not Dictionary<string, object?> line)
return null;
var start = ReadPoint(line, "start");
var end = ReadPoint(line, "end");
return start.Count >= 2 && end.Count >= 2 ? (start, end) : null;
}
static string? ExtractSingleLetterLabel(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var cleaned = Regex.Replace(text, @"\\[^;]*;", "");
cleaned = Regex.Replace(cleaned, @"[{}\\]", "").Trim();
var match = Regex.Match(cleaned, @"^[A-Z]$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return match.Success ? match.Value.ToUpperInvariant() : null;
}
static string ClassifySectionCutView(BoundingBox symbolBox, BoundingBox mainBox, BoundingBox? bottomBox)
{
var mainOverlap = OverlapArea(symbolBox, mainBox);
var bottomOverlap = bottomBox == null ? 0.0 : OverlapArea(symbolBox, bottomBox.Value);
if (mainOverlap <= 0 && bottomOverlap <= 0)
return "unknown";
return mainOverlap >= bottomOverlap ? "main_view" : "bottom_view_candidate";
}
static double OverlapArea(BoundingBox a, BoundingBox b)
{
var width = Math.Max(0.0, Math.Min(a.MaxX, b.MaxX) - Math.Max(a.MinX, b.MinX));
var height = Math.Max(0.0, Math.Min(a.MaxY, b.MaxY) - Math.Max(a.MinY, b.MinY));
return width * height;
}
static void AttachRegionSignature(
Dictionary<string, object?> region,
List<Dictionary<string, object?>> geometry,
string scope)
{
if (!region.TryGetValue("ok", out var ok) || ok is not true)
return;
var handles = ReadStringList(region, "source_handles")
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (handles.Count == 0)
return;
var regionGeometry = geometry
.Where(item => handles.Contains(ReadDictString(item, "handle")))
.ToList();
var normalized = NormalizePrimitiveSet(regionGeometry, scope);
var signature = BuildViewSignature(normalized.Normalized, normalized.MergedLines, scope);
signature["normalization_summary"] = normalized.Summary;
signature["source_handle_count"] = handles.Count;
signature["raw_geometry_count"] = regionGeometry.Count;
region["signature"] = signature;
}
static List<SectionSymbol> FindSectionSymbols(List<Dictionary<string, object?>> annotations)
{
var result = new List<SectionSymbol>();
foreach (var annotation in annotations)
{
var text = ReadDictString(annotation, "text");
if (string.IsNullOrWhiteSpace(text))
continue;
var match = Regex.Match(
text,
@"(?<![A-Z0-9])([A-Z0-9]{1,4})\s*-\s*\1(?![A-Z0-9])(?:\s*/?\s*\d+(?:\.\d+)?\s*:\s*\d+(?:\.\d+)?)?",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
if (!match.Success)
continue;
var box = ReadAnnotationBox(annotation);
if (box == null)
continue;
var labelToken = match.Groups[1].Value.ToUpperInvariant();
result.Add(new SectionSymbol(labelToken + "-" + labelToken, match.Value, text, box.Value, annotation));
}
return result;
}
static BoundingBox? ReadAnnotationBox(Dictionary<string, object?> annotation)
{
if (ReadEntityBox(annotation, "bbox") is { } box)
return box;
var point = ReadPoint(annotation, "insertion_point");
if (point.Count < 2)
point = ReadPoint(annotation, "text_position");
if (point.Count < 2)
return null;
var height = ReadDictDouble(annotation, "height") ?? 1.0;
return new BoundingBox(point[0] - height, point[1] - height, point[0] + height, point[1] + height);
}
static PrimitiveNode? FindSectionSeed(
SectionSymbol symbol,
List<PrimitiveNode> nodes,
HashSet<string> mainHandles,
HashSet<string> existingSectionHandles,
string probeDirection)
{
var searchHalfWidth = Math.Max(20.0, symbol.Box.Width * 8.0);
var candidates = nodes
.Where(node => !NodeHasAnyHandle(node, mainHandles))
.Where(node => !NodeHasAnyHandle(node, existingSectionHandles))
.Where(node => Math.Abs(node.Box.CenterX - symbol.Box.CenterX) <= searchHalfWidth ||
RangesOverlap(node.Box.MinX, node.Box.MaxX, symbol.Box.MinX - searchHalfWidth, symbol.Box.MaxX + searchHalfWidth));
if (string.Equals(probeDirection, "up", StringComparison.OrdinalIgnoreCase))
{
return candidates
.Where(node => node.Box.MinY >= symbol.Box.MaxY + ViewRegionProbeTolerance)
.OrderBy(node => node.Box.MinY - symbol.Box.MaxY)
.ThenBy(node => Math.Abs(node.Box.CenterX - symbol.Box.CenterX))
.FirstOrDefault();
}
return candidates
.Where(node => node.Box.MaxY <= symbol.Box.MinY - ViewRegionProbeTolerance)
.OrderBy(node => symbol.Box.MinY - node.Box.MaxY)
.ThenBy(node => Math.Abs(node.Box.CenterX - symbol.Box.CenterX))
.FirstOrDefault();
}
static Dictionary<string, object?> ProbeOrthographicView(
string direction,
string kind,
BoundingBox mainBox,
List<PrimitiveNode> nodes,
HashSet<string> mainHandles,
HashSet<string> sectionHandles)
{
var seed = FindRayHitFromMainView(direction, mainBox, nodes, mainHandles);
if (seed == null)
{
return new Dictionary<string, object?>
{
["ok"] = false,
["kind"] = kind,
["direction"] = direction,
["reason"] = "no_ray_hit"
};
}
if (NodeHasAnyHandle(seed, sectionHandles))
{
return new Dictionary<string, object?>
{
["ok"] = false,
["kind"] = kind,
["direction"] = direction,
["reason"] = "first_ray_hit_belongs_to_section_view",
["seed"] = NodeToDictionary(seed)
};
}
var excluded = new HashSet<string>(mainHandles, StringComparer.OrdinalIgnoreCase);
excluded.UnionWith(sectionHandles);
var region = ExpandRegionFromSeed(seed, nodes, excluded);
return new Dictionary<string, object?>
{
["ok"] = true,
["kind"] = kind,
["direction"] = direction,
["seed"] = NodeToDictionary(seed),
["bbox"] = region.Box.ToDictionary(),
["geometry_node_count"] = region.Nodes.Count,
["source_handle_count"] = region.Handles.Count,
["source_handles"] = region.Handles.OrderBy(handle => handle, StringComparer.OrdinalIgnoreCase).ToList()
};
}
static PrimitiveNode? FindRayHitFromMainView(
string direction,
BoundingBox mainBox,
List<PrimitiveNode> nodes,
HashSet<string> mainHandles)
{
if (string.Equals(direction, "down", StringComparison.OrdinalIgnoreCase))
{
var x = mainBox.CenterX;
var candidates = nodes
.Where(node => !NodeHasAnyHandle(node, mainHandles))
.Where(node => node.Box.MaxY <= mainBox.MinY - ViewRegionProbeTolerance)
.Where(node => x >= node.Box.MinX - ViewRegionProbeTolerance && x <= node.Box.MaxX + ViewRegionProbeTolerance)
.OrderBy(node => mainBox.MinY - node.Box.MaxY)
.ToList();
return candidates.FirstOrDefault();
}
if (string.Equals(direction, "right", StringComparison.OrdinalIgnoreCase))
{
var y = mainBox.CenterY;
var candidates = nodes
.Where(node => !NodeHasAnyHandle(node, mainHandles))
.Where(node => node.Box.MinX >= mainBox.MaxX + ViewRegionProbeTolerance)
.Where(node => y >= node.Box.MinY - ViewRegionProbeTolerance && y <= node.Box.MaxY + ViewRegionProbeTolerance)
.OrderBy(node => node.Box.MinX - mainBox.MaxX)
.ToList();
return candidates.FirstOrDefault();
}
return null;
}
static ViewRegion ExpandRegionFromSeed(PrimitiveNode seed, List<PrimitiveNode> nodes, HashSet<string> excludedHandles)
{
var available = nodes
.Where(node => ReferenceEquals(node, seed) || !NodeHasAnyHandle(node, excludedHandles))
.ToList();
var selected = ExpandConnectedNodes(seed, available);
var selectedBox = BoundingBox.Union(selected.Select(node => node.Box));
var changed = true;
while (changed)
{
changed = false;
foreach (var node in available)
{
if (selected.Contains(node))
continue;
var expandedBox = selectedBox.Expand(ViewRegionSpatialExpansionTolerance);
if (!expandedBox.ContainsBox(node.Box) &&
!BoxesOverlap(selectedBox, node.Box, ViewRegionSpatialExpansionTolerance) &&
!selected.Any(item => AreConnected(item, node)))
{
continue;
}
selected.Add(node);
selectedBox = BoundingBox.Union(selected.Select(item => item.Box));
changed = true;
}
}
var handles = selected
.SelectMany(node => node.SourceHandles)
.Where(handle => !string.IsNullOrWhiteSpace(handle))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return new ViewRegion(selected, selectedBox, handles);
}
static bool NodeHasAnyHandle(PrimitiveNode node, HashSet<string> handles)
{
return node.SourceHandles.Any(handle => handles.Contains(handle));
}
static Dictionary<string, object?> NodeToDictionary(PrimitiveNode node)
{
return new Dictionary<string, object?>
{
["kind"] = node.Kind,
["bbox"] = node.Box.ToDictionary(),
["source_handles"] = node.SourceHandles,
["object_name"] = ReadDictString(node.Data, "object_name"),
["layer"] = ReadDictString(node.Data, "layer"),
["linetype"] = ReadDictString(node.Data, "linetype")
};
}
static bool RangesOverlap(double aMin, double aMax, double bMin, double bMax)
{
return aMin <= bMax && aMax >= bMin;
}
static bool BoxesOverlap(BoundingBox a, BoundingBox b, double tolerance)
{
return RangesOverlap(a.MinX - tolerance, a.MaxX + tolerance, b.MinX, b.MaxX) &&
RangesOverlap(a.MinY - tolerance, a.MaxY + tolerance, b.MinY, b.MaxY);
}
static BoundingBox? ReadEntityBox(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
return null;
if (value is not Dictionary<string, object?> dict)
return null;
var minX = ReadDictDouble(dict, "min_x");
var minY = ReadDictDouble(dict, "min_y");
var maxX = ReadDictDouble(dict, "max_x");
var maxY = ReadDictDouble(dict, "max_y");
if (minX == null || minY == null || maxX == null || maxY == null)
return null;
return new BoundingBox(minX.Value, minY.Value, maxX.Value, maxY.Value);
}
static bool IsOuterDrawingEdgeNode(PrimitiveNode node, BoundingBox drawingBox)
{
var tolerance = Math.Max(MainViewConnectTolerance, Math.Min(drawingBox.Width, drawingBox.Height) * 0.002);
return Math.Abs(node.Box.MinX - drawingBox.MinX) <= tolerance ||
Math.Abs(node.Box.MaxX - drawingBox.MaxX) <= tolerance ||
Math.Abs(node.Box.MinY - drawingBox.MinY) <= tolerance ||
Math.Abs(node.Box.MaxY - drawingBox.MaxY) <= tolerance;
}
static bool IsPreferredMainViewSeed(PrimitiveNode node)
{
var layer = ReadDictString(node.Data, "layer");
var linetype = ReadDictString(node.Data, "linetype");
var lineweight = ReadDictInt(node.Data, "lineweight");
if (layer.Contains("粗实线", StringComparison.OrdinalIgnoreCase))
return true;
return string.Equals(linetype, "Continuous", StringComparison.OrdinalIgnoreCase) &&
lineweight != null &&
lineweight > 0;
}
static BoundingBox BoundingBoxForViewBbox(
IEnumerable<PrimitiveNode> nodes,
out int excludedCenterlineNodeCount,
out int excludedSectionCutSymbolNodeCount)
{
var nodeList = nodes.ToList();
var sectionCutSymbolNodes = FindSectionCutSymbolNodes(nodeList);
var bboxNodes = nodeList
.Where(node => !IsCenterlineNode(node) && !sectionCutSymbolNodes.Contains(node))
.ToList();
excludedCenterlineNodeCount = nodeList.Count(IsCenterlineNode);
excludedSectionCutSymbolNodeCount = sectionCutSymbolNodes.Count;
if (bboxNodes.Count == 0)
{
excludedCenterlineNodeCount = 0;
excludedSectionCutSymbolNodeCount = 0;
bboxNodes = nodeList;
}
return BoundingBox.Union(bboxNodes.Select(node => node.Box));
}
static HashSet<PrimitiveNode> FindSectionCutSymbolNodes(List<PrimitiveNode> nodes)
{
var result = new HashSet<PrimitiveNode>();
var lineNodes = nodes
.Where(node => string.Equals(node.Kind, "line", StringComparison.OrdinalIgnoreCase))
.ToList();
var phantomLines = lineNodes
.Where(node => string.Equals(ReadDictString(node.Data, "linetype"), "PHANTOM", StringComparison.OrdinalIgnoreCase))
.Where(node => Math.Max(node.Box.Width, node.Box.Height) >= 20.0)
.ToList();
foreach (var phantom in phantomLines)
{
var isVertical = phantom.Box.Width <= MainViewConnectTolerance && phantom.Box.Height > MainViewConnectTolerance;
var isHorizontal = phantom.Box.Height <= MainViewConnectTolerance && phantom.Box.Width > MainViewConnectTolerance;
if (!isVertical && !isHorizontal)
continue;
var symbolLeaders = lineNodes
.Where(node => !ReferenceEquals(node, phantom))
.Where(node => IsSectionCutLeaderForPhantom(node, phantom, isVertical))
.ToList();
if (symbolLeaders.Count < 2)
continue;
result.Add(phantom);
foreach (var leader in symbolLeaders)
result.Add(leader);
}
return result;
}
static bool IsSectionCutLeaderForPhantom(PrimitiveNode node, PrimitiveNode phantom, bool phantomIsVertical)
{
var tolerance = Math.Max(3.0, MainViewConnectTolerance);
var length = Math.Max(node.Box.Width, node.Box.Height);
if (length < 2.0 || length > 25.0)
return false;
if (phantomIsVertical)
{
if (!IsHorizontalLineNode(node, MainViewConnectTolerance))
return false;
var nearPhantomX = Math.Abs(node.Box.MinX - phantom.Box.MinX) <= tolerance ||
Math.Abs(node.Box.MaxX - phantom.Box.MinX) <= tolerance ||
(node.Box.MinX <= phantom.Box.MinX && node.Box.MaxX >= phantom.Box.MinX);
var nearPhantomEndY = Math.Abs(node.Box.CenterY - phantom.Box.MinY) <= tolerance ||
Math.Abs(node.Box.CenterY - phantom.Box.MaxY) <= tolerance;
return nearPhantomX && nearPhantomEndY;
}
if (!IsVerticalLineNode(node, MainViewConnectTolerance))
return false;
var nearPhantomY = Math.Abs(node.Box.MinY - phantom.Box.MinY) <= tolerance ||
Math.Abs(node.Box.MaxY - phantom.Box.MinY) <= tolerance ||
(node.Box.MinY <= phantom.Box.MinY && node.Box.MaxY >= phantom.Box.MinY);
var nearPhantomEndX = Math.Abs(node.Box.CenterX - phantom.Box.MinX) <= tolerance ||
Math.Abs(node.Box.CenterX - phantom.Box.MaxX) <= tolerance;
return nearPhantomY && nearPhantomEndX;
}
static bool IsCenterlineNode(PrimitiveNode node)
{
return ContainsCenterlineMarker(ReadDictString(node.Data, "layer")) ||
ContainsCenterlineMarker(ReadDictString(node.Data, "linetype")) ||
ContainsCenterlineMarker(ReadDictString(node.Data, "style_key"));
}
static bool ContainsCenterlineMarker(string value)
{
return value.Contains("中心", StringComparison.OrdinalIgnoreCase) ||
value.Contains("CENTER", StringComparison.OrdinalIgnoreCase) ||
value.Contains("CENTRE", StringComparison.OrdinalIgnoreCase);
}
static bool IsVerticalLineNode(PrimitiveNode node, double tolerance) =>
string.Equals(node.Kind, "line", StringComparison.OrdinalIgnoreCase) &&
node.Box.Width <= tolerance &&
node.Box.Height > tolerance;
static bool IsHorizontalLineNode(PrimitiveNode node, double tolerance) =>
string.Equals(node.Kind, "line", StringComparison.OrdinalIgnoreCase) &&
node.Box.Height <= tolerance &&
node.Box.Width > tolerance;
static bool IsFrameOrBorderNode(PrimitiveNode node, BoundingBox drawingBox)
{
var layer = ReadDictString(node.Data, "layer");
var linetype = ReadDictString(node.Data, "linetype");
var width = drawingBox.Width;
var height = drawingBox.Height;
var nodeWidth = node.Box.Width;
var nodeHeight = node.Box.Height;
var edgeTolerance = Math.Max(MainViewConnectTolerance, Math.Min(width, height) * 0.08);
var nearOuterEdge =
Math.Abs(node.Box.MinX - drawingBox.MinX) <= MainViewConnectTolerance ||
Math.Abs(node.Box.MaxX - drawingBox.MaxX) <= MainViewConnectTolerance ||
Math.Abs(node.Box.MinY - drawingBox.MinY) <= MainViewConnectTolerance ||
Math.Abs(node.Box.MaxY - drawingBox.MaxY) <= MainViewConnectTolerance;
var nearFrameMargin =
Math.Abs(node.Box.MinX - drawingBox.MinX) <= edgeTolerance ||
Math.Abs(node.Box.MaxX - drawingBox.MaxX) <= edgeTolerance ||
Math.Abs(node.Box.MinY - drawingBox.MinY) <= edgeTolerance ||
Math.Abs(node.Box.MaxY - drawingBox.MaxY) <= edgeTolerance;
var longHorizontal = nodeHeight <= MainViewConnectTolerance && nodeWidth >= width * 0.5;
var longVertical = nodeWidth <= MainViewConnectTolerance && nodeHeight >= height * 0.5;
var frameLayerStyle =
string.Equals(layer, "0", StringComparison.OrdinalIgnoreCase) ||
string.Equals(linetype, "ByLayer", StringComparison.OrdinalIgnoreCase);
return (nearOuterEdge || nearFrameMargin) && (longHorizontal || longVertical) && frameLayerStyle;
}
static List<PrimitiveNode> BuildRawGeometryNodes(List<Dictionary<string, object?>> geometry)
{
var result = new List<PrimitiveNode>();
foreach (var entity in geometry)
{
var node = PrimitiveNode.FromRawGeometry(entity);
if (node != null)
result.Add(node);
}
return result;
}
static HashSet<PrimitiveNode> ExpandConnectedNodes(PrimitiveNode seed, List<PrimitiveNode> lineNodes)
{
var selected = new HashSet<PrimitiveNode> { seed };
var queue = new Queue<PrimitiveNode>();
queue.Enqueue(seed);
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var candidate in lineNodes)
{
if (selected.Contains(candidate))
continue;
if (!AreConnected(current, candidate))
continue;
selected.Add(candidate);
queue.Enqueue(candidate);
}
}
return selected;
}
static bool AreConnected(PrimitiveNode a, PrimitiveNode b)
{
if (string.Equals(a.Kind, "line", StringComparison.OrdinalIgnoreCase) &&
string.Equals(b.Kind, "line", StringComparison.OrdinalIgnoreCase) &&
a.AnchorPoints.Count >= 2 &&
b.AnchorPoints.Count >= 2)
{
return SegmentDistance(a.AnchorPoints[0], a.AnchorPoints[1], b.AnchorPoints[0], b.AnchorPoints[1]) <= MainViewConnectTolerance;
}
foreach (var pa in a.AnchorPoints)
{
foreach (var pb in b.AnchorPoints)
{
if (Distance(pa, pb) <= MainViewConnectTolerance)
return true;
}
}
return false;
}
static double SegmentDistance(List<double> a0, List<double> a1, List<double> b0, List<double> b1)
{
if (SegmentsIntersect(a0, a1, b0, b1))
return 0.0;
return new[]
{
PointSegmentDistance(a0, b0, b1),
PointSegmentDistance(a1, b0, b1),
PointSegmentDistance(b0, a0, a1),
PointSegmentDistance(b1, a0, a1)
}.Min();
}
static bool SegmentsIntersect(List<double> a0, List<double> a1, List<double> b0, List<double> b1)
{
if (a0.Count < 2 || a1.Count < 2 || b0.Count < 2 || b1.Count < 2)
return false;
var d1 = Cross(a0, a1, b0);
var d2 = Cross(a0, a1, b1);
var d3 = Cross(b0, b1, a0);
var d4 = Cross(b0, b1, a1);
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
return true;
return Math.Abs(d1) <= MainViewConnectTolerance && PointOnSegment(b0, a0, a1) ||
Math.Abs(d2) <= MainViewConnectTolerance && PointOnSegment(b1, a0, a1) ||
Math.Abs(d3) <= MainViewConnectTolerance && PointOnSegment(a0, b0, b1) ||
Math.Abs(d4) <= MainViewConnectTolerance && PointOnSegment(a1, b0, b1);
}
static double Cross(List<double> a, List<double> b, List<double> p)
{
return (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
}
static bool PointOnSegment(List<double> p, List<double> a, List<double> b)
{
return p[0] >= Math.Min(a[0], b[0]) - MainViewConnectTolerance &&
p[0] <= Math.Max(a[0], b[0]) + MainViewConnectTolerance &&
p[1] >= Math.Min(a[1], b[1]) - MainViewConnectTolerance &&
p[1] <= Math.Max(a[1], b[1]) + MainViewConnectTolerance;
}
static double PointSegmentDistance(List<double> p, List<double> a, List<double> b)
{
if (p.Count < 2 || a.Count < 2 || b.Count < 2)
return double.MaxValue;
var dx = b[0] - a[0];
var dy = b[1] - a[1];
var len2 = dx * dx + dy * dy;
if (len2 <= 1e-12)
return Distance(p, a);
var t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
t = Math.Max(0.0, Math.Min(1.0, t));
var proj = new List<double> { a[0] + t * dx, a[1] + t * dy };
return Distance(p, proj);
}
static double Distance(List<double> a, List<double> b)
{
if (a.Count < 2 || b.Count < 2)
return double.MaxValue;
var dx = a[0] - b[0];
var dy = a[1] - b[1];
return Math.Sqrt(dx * dx + dy * dy);
}
static List<string> ReadStringList(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
return new List<string>();
if (value is IEnumerable<string> strings)
return strings.Where(text => !string.IsNullOrWhiteSpace(text)).ToList();
if (value is IEnumerable<object> objects)
return objects.Select(obj => Convert.ToString(obj, CultureInfo.InvariantCulture) ?? "").Where(text => !string.IsNullOrWhiteSpace(text)).ToList();
return new List<string>();
}
static Dictionary<string, object?> BuildViewSignature(
List<Dictionary<string, object?>> normalizedGeometry,
List<Dictionary<string, object?>> mergedLines,
string scope)
{
var nonLine = normalizedGeometry
.Where(item => !string.Equals(ReadDictString(item, "kind"), "line", StringComparison.OrdinalIgnoreCase))
.ToList();
var normalizedLines = normalizedGeometry
.Where(item => string.Equals(ReadDictString(item, "kind"), "line", StringComparison.OrdinalIgnoreCase))
.ToList();
var lines = mergedLines;
var circles = nonLine.Where(item => string.Equals(ReadDictString(item, "kind"), "circle", StringComparison.OrdinalIgnoreCase)).ToList();
var arcs = nonLine.Where(item => string.Equals(ReadDictString(item, "kind"), "arc", StringComparison.OrdinalIgnoreCase)).ToList();
var splines = nonLine.Where(item => string.Equals(ReadDictString(item, "kind"), "spline", StringComparison.OrdinalIgnoreCase)).ToList();
var lineLengths = lines.Select(item => ReadDictDouble(item, "length")).Where(value => value != null).Select(value => value!.Value).ToList();
var circleRadii = circles.Select(item => ReadDictDouble(item, "radius")).Where(value => value != null).Select(value => value!.Value).ToList();
var arcRadii = arcs.Select(item => ReadDictDouble(item, "radius")).Where(value => value != null).Select(value => value!.Value).ToList();
var arcSweepAngles = arcs.Select(ArcSweepDeg).Where(value => value != null).Select(value => value!.Value).ToList();
var arcLengths = arcs
.Select(item =>
{
var radius = ReadDictDouble(item, "radius");
var sweep = ArcSweepDeg(item);
return radius != null && sweep != null ? radius.Value * sweep.Value * Math.PI / 180.0 : (double?)null;
})
.Where(value => value != null)
.Select(value => value!.Value)
.ToList();
return new Dictionary<string, object?>
{
["scope"] = scope,
["coordinate_policy"] = "relative_origin_from_leftmost_vertical_line_top_point",
["primitive_counts"] = new Dictionary<string, object?>
{
["line"] = lines.Count,
["circle"] = circles.Count,
["arc"] = arcs.Count,
["spline"] = splines.Count,
["total"] = lines.Count + circles.Count + arcs.Count + splines.Count
},
["line_direction_counts"] = BuildLineDirectionCounts(lines),
["relative_line_coordinates"] = BuildRelativeLineCoordinates(normalizedLines),
["line_length_histogram"] = Histogram(lineLengths, 1.0),
["line_length_stats"] = Stats(lineLengths),
["circle_radius_histogram"] = Histogram(circleRadii, 0.5),
["circle_radius_stats"] = Stats(circleRadii),
["arc_radius_histogram"] = Histogram(arcRadii, 0.5),
["arc_radius_stats"] = Stats(arcRadii),
["arc_sweep_angle_histogram"] = Histogram(arcSweepAngles, 5.0),
["arc_length_histogram"] = Histogram(arcLengths, 1.0),
["matching_primary_fields"] = new[]
{
"kind",
"length",
"radius",
"diameter",
"arc_length",
"sweep_angle",
"direction_class",
"angle_deg",
"relative_start",
"relative_end",
"relative_midpoint"
},
["matching_excluded_primary_fields"] = new[]
{
"start",
"end",
"center",
"absolute_start",
"absolute_end"
}
};
}
static Dictionary<string, object?> BuildRelativeLineCoordinates(List<Dictionary<string, object?>> lines)
{
var parsedLines = lines
.Select(LinePrimitive.FromDictionary)
.Where(item => item != null)
.Cast<LinePrimitive>()
.ToList();
var verticalLines = parsedLines
.Where(line => string.Equals(DirectionClass(line.AngleDeg), "vertical", StringComparison.OrdinalIgnoreCase))
.ToList();
if (verticalLines.Count == 0)
{
return new Dictionary<string, object?>
{
["ok"] = false,
["reason"] = "no_vertical_line_available",
["origin_rule"] = "leftmost_vertical_line_top_point"
};
}
var originLine = verticalLines
.OrderBy(line => Math.Min(line.StartX, line.EndX))
.ThenByDescending(line => Math.Max(line.StartY, line.EndY))
.ThenByDescending(line => line.Length)
.First();
var originX = originLine.StartY >= originLine.EndY ? originLine.StartX : originLine.EndX;
var originY = Math.Max(originLine.StartY, originLine.EndY);
var relativeLines = parsedLines
.Select((line, index) => BuildRelativeLineRecord(line, index + 1, originX, originY))
.OrderBy(item => Convert.ToDouble(item["relative_mid_x"], CultureInfo.InvariantCulture))
.ThenByDescending(item => Convert.ToDouble(item["relative_mid_y"], CultureInfo.InvariantCulture))
.ThenBy(item => Convert.ToDouble(item["length"], CultureInfo.InvariantCulture))
.ToList();
return new Dictionary<string, object?>
{
["ok"] = true,
["origin_rule"] = "leftmost_vertical_line_top_point",
["origin"] = new Dictionary<string, object?>
{
["x"] = originX,
["y"] = originY,
["source_handles"] = originLine.SourceHandles,
["line"] = new Dictionary<string, object?>
{
["start"] = new[] { originLine.StartX, originLine.StartY },
["end"] = new[] { originLine.EndX, originLine.EndY },
["length"] = originLine.Length,
["angle_deg"] = originLine.AngleDeg
}
},
["line_count"] = relativeLines.Count,
["bucket_size"] = 1.0,
["relative_midpoints"] = relativeLines.Select(item => new Dictionary<string, object?>
{
["index"] = item["index"],
["source_handles"] = item["source_handles"],
["x"] = item["relative_mid_x"],
["y"] = item["relative_mid_y"],
["x_bucket"] = item["relative_mid_x_bucket"],
["y_bucket"] = item["relative_mid_y_bucket"],
["midpoint_key"] = item["midpoint_key"]
}).ToList(),
["relative_midpoint_histogram"] = Histogram(relativeLines
.Select(item => Convert.ToString(item["midpoint_key"], CultureInfo.InvariantCulture) ?? "")
.Where(key => !string.IsNullOrWhiteSpace(key))
.ToList()),
["lines"] = relativeLines,
["feature_histogram"] = Histogram(relativeLines
.Select(item => Convert.ToString(item["feature_key"], CultureInfo.InvariantCulture) ?? "")
.Where(key => !string.IsNullOrWhiteSpace(key))
.ToList())
};
}
static Dictionary<string, object?> BuildRelativeLineRecord(LinePrimitive line, int index, double originX, double originY)
{
var rsx = line.StartX - originX;
var rsy = line.StartY - originY;
var rex = line.EndX - originX;
var rey = line.EndY - originY;
var midX = (rsx + rex) / 2.0;
var midY = (rsy + rey) / 2.0;
var direction = DirectionClass(line.AngleDeg);
var lengthBucket = Bucket(line.Length, 1.0);
var midXBucket = Bucket(midX, 1.0);
var midYBucket = Bucket(midY, 1.0);
var midpointKey = $"X{midXBucket:0.###}|Y{midYBucket:0.###}";
return new Dictionary<string, object?>
{
["index"] = index,
["source_handles"] = line.SourceHandles,
["direction_class"] = direction,
["angle_deg"] = line.AngleDeg,
["length"] = line.Length,
["length_bucket"] = lengthBucket,
["relative_start"] = new[] { rsx, rsy },
["relative_end"] = new[] { rex, rey },
["relative_midpoint"] = new[] { midX, midY },
["relative_mid_x"] = midX,
["relative_mid_y"] = midY,
["relative_mid_x_bucket"] = midXBucket,
["relative_mid_y_bucket"] = midYBucket,
["midpoint_key"] = midpointKey,
["feature_key"] = $"{direction}|L{lengthBucket:0.###}|X{midXBucket:0.###}|Y{midYBucket:0.###}"
};
}
static double Bucket(double value, double bucketSize)
{
return bucketSize <= 0 ? value : Math.Round(value / bucketSize) * bucketSize;
}
static Dictionary<string, int> Histogram(List<string> values)
{
var result = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var value in values)
result[value] = result.TryGetValue(value, out var count) ? count + 1 : 1;
return result.OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(item => item.Key, item => item.Value, StringComparer.OrdinalIgnoreCase);
}
static Dictionary<string, int> BuildLineDirectionCounts(List<Dictionary<string, object?>> lines)
{
var result = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["horizontal"] = 0,
["vertical"] = 0,
["diagonal"] = 0,
["unknown"] = 0
};
foreach (var line in lines)
{
var angle = ReadDictDouble(line, "angle_deg");
var key = angle == null ? "unknown" : DirectionClass(angle.Value);
result[key] = result.TryGetValue(key, out var count) ? count + 1 : 1;
}
return result;
}
static string DirectionClass(double angleDeg)
{
var normalized = NormalizeAngleDeg(angleDeg);
if (AngleDistance(normalized, 0) <= 5.0 || AngleDistance(normalized, 180) <= 5.0)
return "horizontal";
if (AngleDistance(normalized, 90) <= 5.0)
return "vertical";
return "diagonal";
}
static double AngleDistance(double a, double b)
{
var delta = Math.Abs(a - b) % 180.0;
return Math.Min(delta, 180.0 - delta);
}
static Dictionary<string, int> Histogram(List<double> values, double bucketSize)
{
var result = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
if (bucketSize <= 0)
return result;
foreach (var value in values.Where(value => !double.IsNaN(value) && !double.IsInfinity(value)))
{
var bucket = Math.Round(value / bucketSize) * bucketSize;
var key = bucket.ToString("0.###", CultureInfo.InvariantCulture);
result[key] = result.TryGetValue(key, out var count) ? count + 1 : 1;
}
return result.OrderBy(item => double.Parse(item.Key, CultureInfo.InvariantCulture)).ToDictionary(item => item.Key, item => item.Value, StringComparer.OrdinalIgnoreCase);
}
static Dictionary<string, object?> Stats(List<double> values)
{
if (values.Count == 0)
{
return new Dictionary<string, object?>
{
["count"] = 0
};
}
return new Dictionary<string, object?>
{
["count"] = values.Count,
["min"] = values.Min(),
["max"] = values.Max(),
["sum"] = values.Sum(),
["avg"] = values.Average()
};
}
static double? ArcSweepDeg(Dictionary<string, object?> item)
{
var start = ReadDictDouble(item, "start_angle");
var end = ReadDictDouble(item, "end_angle");
if (start == null || end == null)
return null;
var sweep = (end.Value - start.Value) * 180.0 / Math.PI;
while (sweep < 0)
sweep += 360.0;
while (sweep >= 360.0)
sweep -= 360.0;
return sweep;
}
static Manifest WriteFiles(string outputDir, string drawingPath, string progId, bool started, DocumentExtraction extraction)
{
var files = new List<CategoryFile>
{
WriteCategory(outputDir, "entities-common", "entities-common.json", extraction.EntitiesCommon),
WriteCategory(outputDir, "geometry", "geometry.json", extraction.Geometry),
WriteCategory(outputDir, "normalized-geometry", "normalized-geometry.json", extraction.NormalizedGeometry),
WriteCategory(outputDir, "merged-lines", "merged-lines.json", extraction.MergedLines),
WriteCategory(outputDir, "geometry-overlaps", "geometry-overlaps.json", extraction.GeometryOverlaps),
WriteCategory(outputDir, "normalization-summary", "normalization-summary.json", extraction.NormalizationSummary),
WriteCategory(outputDir, "main-view-geometry", "main-view-geometry.json", extraction.MainViewGeometry),
WriteCategory(outputDir, "main-view-normalized-geometry", "main-view-normalized-geometry.json", extraction.MainViewNormalizedGeometry),
WriteCategory(outputDir, "main-view-merged-lines", "main-view-merged-lines.json", extraction.MainViewMergedLines),
WriteCategory(outputDir, "main-view-overlaps", "main-view-overlaps.json", extraction.MainViewOverlaps),
WriteCategory(outputDir, "main-view-signature", "main-view-signature.json", extraction.MainViewSignature),
WriteCategory(outputDir, "bottom-view-signature", "bottom-view-signature.json", extraction.BottomViewSignature),
WriteCategory(outputDir, "section-cut-symbols", "section-cut-symbols.json", extraction.SectionCutSymbols),
WriteCategory(outputDir, "main-view-selection", "main-view-selection.json", extraction.MainViewSelection),
WriteCategory(outputDir, "view-regions", "view-regions.json", extraction.ViewRegions),
WriteCategory(outputDir, "annotations", "annotations.json", extraction.Annotations),
WriteCategory(outputDir, "dimensions", "dimensions.json", extraction.Dimensions),
WriteCategory(outputDir, "dimensional-tolerances", "dimensional-tolerances.json", extraction.DimensionalTolerances),
WriteCategory(outputDir, "roughness", "roughness.json", extraction.Roughness),
WriteCategory(outputDir, "geometric-tolerances", "geometric-tolerances.json", extraction.GeometricTolerances),
WriteCategory(outputDir, "datums", "datums.json", extraction.Datums),
WriteCategory(outputDir, "unknown-entities", "unknown-entities.json", extraction.UnknownEntities)
};
var manifest = new Manifest
{
Ok = true,
Schema = "agent4.dwg-draft.extraction.v1",
Source = "AutoCAD COM external extractor",
ProgId = progId,
StartedAutoCad = started,
DrawingPath = drawingPath,
ExportedAtUtc = DateTime.UtcNow,
ModelSpaceCount = extraction.ModelSpaceCount,
PaperSpaceCount = extraction.PaperSpaceCount,
EntityCount = extraction.EntitiesCommon.Count,
TypeCounts = extraction.TypeCounts,
CategoryFiles = files
};
WriteJson(Path.Combine(outputDir, "manifest.json"), manifest);
return manifest;
}
static DocumentExtraction LoadExistingExtraction(string inputDir)
{
if (string.IsNullOrWhiteSpace(inputDir))
throw new ArgumentException("--reuse-extraction-dir is required for existing extraction reanalysis.");
inputDir = Path.GetFullPath(inputDir.Trim().Trim('"'));
if (!Directory.Exists(inputDir))
throw new DirectoryNotFoundException($"Existing extraction directory not found: {inputDir}");
var extraction = new DocumentExtraction
{
EntitiesCommon = ReadJsonListFile(Path.Combine(inputDir, "entities-common.json")),
Geometry = ReadJsonListFile(Path.Combine(inputDir, "geometry.json")),
Annotations = ReadJsonListFile(Path.Combine(inputDir, "annotations.json")),
Dimensions = ReadJsonListFile(Path.Combine(inputDir, "dimensions.json")),
DimensionalTolerances = ReadJsonListFile(Path.Combine(inputDir, "dimensional-tolerances.json")),
Roughness = ReadJsonListFile(Path.Combine(inputDir, "roughness.json")),
GeometricTolerances = ReadJsonListFile(Path.Combine(inputDir, "geometric-tolerances.json")),
Datums = ReadJsonListFile(Path.Combine(inputDir, "datums.json")),
UnknownEntities = ReadJsonListFile(Path.Combine(inputDir, "unknown-entities.json"))
};
extraction.TypeCounts = ReadTypeCounts(Path.Combine(inputDir, "manifest.json"));
extraction.ModelSpaceCount = extraction.EntitiesCommon.Count(item => string.Equals(ReadDictString(item, "space"), "ModelSpace", StringComparison.OrdinalIgnoreCase));
extraction.PaperSpaceCount = extraction.EntitiesCommon.Count(item => string.Equals(ReadDictString(item, "space"), "PaperSpace", StringComparison.OrdinalIgnoreCase));
return extraction;
}
static List<Dictionary<string, object?>> ReadJsonListFile(string path)
{
if (!File.Exists(path))
return new List<Dictionary<string, object?>>();
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.ValueKind != JsonValueKind.Array)
return new List<Dictionary<string, object?>>();
var result = new List<Dictionary<string, object?>>();
foreach (var item in doc.RootElement.EnumerateArray())
{
if (ConvertJsonValue(item) is Dictionary<string, object?> dict)
result.Add(dict);
}
return result;
}
static Dictionary<string, int> ReadTypeCounts(string manifestPath)
{
var result = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(manifestPath))
return result;
using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath));
if (!doc.RootElement.TryGetProperty("type_counts", out var typeCounts) ||
typeCounts.ValueKind != JsonValueKind.Object)
{
return result;
}
foreach (var item in typeCounts.EnumerateObject())
{
if (item.Value.ValueKind == JsonValueKind.Number && item.Value.TryGetInt32(out var count))
result[item.Name] = count;
}
return result;
}
static object? ConvertJsonValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Object => element.EnumerateObject()
.ToDictionary(item => item.Name, item => ConvertJsonValue(item.Value), StringComparer.OrdinalIgnoreCase),
JsonValueKind.Array => ConvertJsonArray(element),
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt32(out var intValue) ? intValue : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => null
};
}
static object ConvertJsonArray(JsonElement element)
{
var values = element.EnumerateArray().Select(ConvertJsonValue).ToList();
if (values.All(value => value is int or double or long or float or decimal))
return values.Select(value => Convert.ToDouble(value, CultureInfo.InvariantCulture)).ToList();
return values;
}
static CategoryFile WriteCategory(string outputDir, string category, string file, object data)
{
var path = Path.Combine(outputDir, file);
WriteJson(path, data);
return new CategoryFile
{
Category = category,
File = file,
FullPath = path,
ItemCount = data is System.Collections.ICollection collection ? collection.Count : 0,
Exists = true
};
}
static void WriteJson(string path, object data)
{
File.WriteAllText(path, JsonSerializer.Serialize(data, JsonDefaults.Options));
}
static object ConnectOrCreate(string progId, bool visible, bool allowStartAutoCad, out bool started)
{
var app = TryGetActiveComObject(progId);
started = false;
if (app != null)
{
TrySetProperty(app, "Visible", visible);
return app;
}
if (!allowStartAutoCad)
{
throw new InvalidOperationException(
$"No active AutoCAD COM object: {progId}. Open AutoCAD manually, or pass --allow-start-autocad true.");
}
if (app == null)
{
LogProgress("start_autocad_process");
try
{
StartAutoCadFromRegistry(progId);
app = WaitForActiveComObject(progId, TimeSpan.FromSeconds(90));
if (app != null)
started = true;
}
catch (Exception ex)
{
LogProgress($"start_autocad_process_failed {ex.GetType().Name}: {ex.Message}");
}
}
if (app == null)
{
try
{
var type = Type.GetTypeFromProgID(progId, throwOnError: true)
?? throw new InvalidOperationException($"找不到 AutoCAD COM ProgID{progId}");
app = Activator.CreateInstance(type)
?? throw new InvalidOperationException($"创建 AutoCAD COM 对象失败:{progId}");
started = true;
}
catch
{
StartAutoCadFromRegistry(progId);
var deadline = DateTime.UtcNow.AddSeconds(90);
while (DateTime.UtcNow < deadline)
{
Thread.Sleep(1500);
app = TryGetActiveComObject(progId);
if (app != null)
{
started = true;
break;
}
}
}
}
if (app == null)
throw new InvalidOperationException($"AutoCAD 启动或 COM 连接失败:{progId}");
TrySetProperty(app, "Visible", visible);
return app;
}
static object? WaitForActiveComObject(string progId, TimeSpan timeout)
{
LogProgress("wait_autocad_com_start");
var deadline = DateTime.UtcNow.Add(timeout);
while (DateTime.UtcNow < deadline)
{
Thread.Sleep(1500);
var app = TryGetActiveComObject(progId);
if (app != null)
{
LogProgress("wait_autocad_com_ok");
return app;
}
}
LogProgress("wait_autocad_com_timeout");
return null;
}
static void StartAutoCadFromRegistry(string progId)
{
var clsid = ReadDefaultValue($@"{progId}\CLSID");
var command = string.IsNullOrWhiteSpace(clsid) ? "" : ReadDefaultValue($@"CLSID\{clsid}\LocalServer32");
var exe = ExtractExePath(command);
if (string.IsNullOrWhiteSpace(exe) || !File.Exists(exe))
exe = @"D:\Program Files\Autodesk\AutoCAD 2020\acad.exe";
if (!File.Exists(exe))
throw new FileNotFoundException($"找不到 acad.exe。ProgID={progId}, LocalServer32={command}");
Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = "/Automation",
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(exe) ?? Environment.CurrentDirectory
});
}
static string ReadDefaultValue(string subKey)
{
using var key = Registry.ClassesRoot.OpenSubKey(subKey);
return key?.GetValue("")?.ToString() ?? "";
}
static string ExtractExePath(string command)
{
if (string.IsNullOrWhiteSpace(command))
return "";
var trimmed = command.Trim();
if (trimmed.StartsWith('"'))
{
var end = trimmed.IndexOf('"', 1);
return end > 1 ? trimmed[1..end] : "";
}
var exeIndex = trimmed.IndexOf(".exe", StringComparison.OrdinalIgnoreCase);
return exeIndex >= 0 ? trimmed[..(exeIndex + 4)] : trimmed;
}
static object OpenDocument(object app, string drawingPath)
{
var documents = GetProperty(app, "Documents") ?? throw new InvalidOperationException("AutoCAD.Application.Documents 不可用。");
try
{
return TryInvoke(documents, "Open", [drawingPath, true])
?? TryInvoke(documents, "Open", [drawingPath])
?? throw new InvalidOperationException($"AutoCAD 打开 DWG 失败:{drawingPath}");
}
finally
{
ReleaseCom(documents);
}
}
static Dictionary<string, SplineEndpoint> ReadSplineEndpointsViaAutoLisp(object app, object doc, string outputDir)
{
var result = new Dictionary<string, SplineEndpoint>(StringComparer.OrdinalIgnoreCase);
try
{
Directory.CreateDirectory(outputDir);
var lispPath = Path.Combine(outputDir, "agent4-read-spline-endpoints.lsp");
var outputPath = Path.Combine(outputDir, "spline-endpoints.tsv");
if (File.Exists(outputPath))
File.Delete(outputPath);
File.WriteAllText(lispPath, BuildSplineEndpointLisp(outputPath));
TryInvoke(doc, "SendCommand", [$"(load \"{LispPath(lispPath)}\")\n"]);
var deadline = DateTime.UtcNow.AddSeconds(30);
while (DateTime.UtcNow < deadline)
{
WaitForQuiescent(app, TimeSpan.FromSeconds(2));
if (File.Exists(outputPath))
break;
Thread.Sleep(250);
}
if (!File.Exists(outputPath))
return result;
foreach (var line in File.ReadAllLines(outputPath))
{
var parts = line.Split('\t');
if (parts.Length < 5)
continue;
if (!TryParseInvariant(parts[1], out var sx) ||
!TryParseInvariant(parts[2], out var sy) ||
!TryParseInvariant(parts[3], out var ex) ||
!TryParseInvariant(parts[4], out var ey))
{
continue;
}
result[parts[0]] = new SplineEndpoint(
new List<double> { sx, sy },
new List<double> { ex, ey },
"autolisp_vlax_curve_param_point");
}
}
catch
{
// Keep extraction usable; direct COM/control-point fallback still runs.
}
return result;
}
static string BuildSplineEndpointLisp(string outputPath)
{
var path = LispPath(outputPath);
return string.Join("\n", new[]
{
"(vl-load-com)",
"(defun agent4-format-point (p)",
" (if (and p (listp p) (numberp (car p)) (numberp (cadr p)))",
" (strcat (rtos (car p) 2 16) \"\\t\" (rtos (cadr p) 2 16))",
" \"\\t\"",
" )",
")",
"(defun agent4-start-point (obj / param point)",
" (setq param (vl-catch-all-apply 'vlax-curve-getStartParam (list obj)))",
" (if (not (vl-catch-all-error-p param))",
" (setq point (vl-catch-all-apply 'vlax-curve-getPointAtParam (list obj param)))",
" )",
" (if (or (null point) (vl-catch-all-error-p point))",
" (setq point (vl-catch-all-apply 'vlax-curve-getStartPoint (list obj)))",
" )",
" (if (vl-catch-all-error-p point) nil point)",
")",
"(defun agent4-end-point (obj / param point)",
" (setq param (vl-catch-all-apply 'vlax-curve-getEndParam (list obj)))",
" (if (not (vl-catch-all-error-p param))",
" (setq point (vl-catch-all-apply 'vlax-curve-getPointAtParam (list obj param)))",
" )",
" (if (or (null point) (vl-catch-all-error-p point))",
" (setq point (vl-catch-all-apply 'vlax-curve-getEndPoint (list obj)))",
" )",
" (if (vl-catch-all-error-p point) nil point)",
")",
"(defun agent4-write-spline-endpoints (/ f ss i ent obj h sp ep)",
$" (setq f (open \"{path}\" \"w\"))",
" (setq ss (ssget \"_X\" '((0 . \"SPLINE\"))))",
" (if ss",
" (progn",
" (setq i 0)",
" (while (< i (sslength ss))",
" (setq ent (ssname ss i))",
" (setq obj (vlax-ename->vla-object ent))",
" (setq h (vla-get-Handle obj))",
" (setq sp (agent4-start-point obj))",
" (setq ep (agent4-end-point obj))",
" (write-line (strcat h \"\\t\" (agent4-format-point sp) \"\\t\" (agent4-format-point ep)) f)",
" (setq i (1+ i))",
" )",
" )",
" )",
" (if f (close f))",
" (princ)",
")",
"(agent4-write-spline-endpoints)"
}) + "\n";
}
static Dictionary<string, DimensionDefinitionPoints> ReadDimensionDefinitionPointsViaAutoLisp(object app, object doc, string outputDir)
{
var result = new Dictionary<string, DimensionDefinitionPoints>(StringComparer.OrdinalIgnoreCase);
try
{
Directory.CreateDirectory(outputDir);
var lispPath = Path.Combine(outputDir, "agent4-read-dimension-definition-points.lsp");
var outputPath = Path.Combine(outputDir, "dimension-definition-points.tsv");
if (File.Exists(outputPath))
File.Delete(outputPath);
File.WriteAllText(lispPath, BuildDimensionDefinitionPointLisp(outputPath));
TryInvoke(doc, "SendCommand", [$"(load \"{LispPath(lispPath)}\")\n"]);
var deadline = DateTime.UtcNow.AddSeconds(30);
while (DateTime.UtcNow < deadline)
{
WaitForQuiescent(app, TimeSpan.FromSeconds(2));
if (File.Exists(outputPath))
break;
Thread.Sleep(250);
}
if (!File.Exists(outputPath))
return result;
foreach (var line in File.ReadAllLines(outputPath))
{
var parts = line.Split('\t');
if (parts.Length < 19)
continue;
result[parts[0]] = new DimensionDefinitionPoints(
ParsePointColumns(parts, 1),
ParsePointColumns(parts, 4),
ParsePointColumns(parts, 7),
ParsePointColumns(parts, 10),
ParsePointColumns(parts, 13),
ParsePointColumns(parts, 16));
}
}
catch
{
// Keep DWG extraction usable; ActiveX point properties remain the fallback.
}
return result;
}
static string BuildDimensionDefinitionPointLisp(string outputPath)
{
var path = LispPath(outputPath);
return string.Join("\n", new[]
{
"(vl-load-com)",
"(defun agent4-format-dxf-point (data code / p)",
" (setq p (cdr (assoc code data)))",
" (if (and p (listp p) (numberp (car p)) (numberp (cadr p)))",
" (strcat (rtos (car p) 2 16) \"\\t\" (rtos (cadr p) 2 16) \"\\t\" (rtos (if (numberp (caddr p)) (caddr p) 0.0) 2 16))",
" \"\\t\\t\"",
" )",
")",
"(defun agent4-write-dimension-definition-points (/ f ss i ent data obj h)",
$" (setq f (open \"{path}\" \"w\"))",
" (setq ss (ssget \"_X\" '((0 . \"*DIMENSION\"))))",
" (if ss",
" (progn",
" (setq i 0)",
" (while (< i (sslength ss))",
" (setq ent (ssname ss i))",
" (setq data (entget ent))",
" (setq obj (vlax-ename->vla-object ent))",
" (setq h (vla-get-Handle obj))",
" (write-line",
" (strcat",
" h \"\\t\"",
" (agent4-format-dxf-point data 13) \"\\t\"",
" (agent4-format-dxf-point data 14) \"\\t\"",
" (agent4-format-dxf-point data 10) \"\\t\"",
" (agent4-format-dxf-point data 11) \"\\t\"",
" (agent4-format-dxf-point data 15) \"\\t\"",
" (agent4-format-dxf-point data 16)",
" )",
" f",
" )",
" (setq i (1+ i))",
" )",
" )",
" )",
" (if f (close f))",
" (princ)",
")",
"(agent4-write-dimension-definition-points)"
}) + "\n";
}
static List<double>? ParsePointColumns(string[] parts, int start)
{
if (parts.Length <= start + 1)
return null;
if (!TryParseInvariant(parts[start], out var x) ||
!TryParseInvariant(parts[start + 1], out var y))
{
return null;
}
var z = parts.Length > start + 2 && TryParseInvariant(parts[start + 2], out var parsedZ)
? parsedZ
: 0.0;
return new List<double> { x, y, z };
}
static Dictionary<string, List<Dictionary<string, object?>>> ReadBlockExplosionsViaAutoLisp(
object app,
object doc,
string outputDir)
{
var result = new Dictionary<string, List<Dictionary<string, object?>>>(StringComparer.OrdinalIgnoreCase);
try
{
Directory.CreateDirectory(outputDir);
var lispPath = Path.Combine(outputDir, "agent4-read-block-explosions.lsp");
var outputPath = Path.Combine(outputDir, "block-explosions.tsv");
if (File.Exists(outputPath))
File.Delete(outputPath);
File.WriteAllText(lispPath, BuildBlockExplosionLisp(outputPath));
TryInvoke(doc, "SendCommand", [$"(load \"{LispPath(lispPath)}\")\n"]);
var deadline = DateTime.UtcNow.AddSeconds(90);
while (DateTime.UtcNow < deadline)
{
WaitForQuiescent(app, TimeSpan.FromSeconds(2));
if (File.Exists(outputPath))
break;
Thread.Sleep(250);
}
if (!File.Exists(outputPath))
return result;
foreach (var line in File.ReadAllLines(outputPath))
{
var parts = line.Split('\t');
if (parts.Length < 16)
continue;
var parent = parts[0];
if (string.IsNullOrWhiteSpace(parent))
continue;
var child = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["index"] = ToInt(parts[1]),
["object_name"] = parts[2],
["text"] = parts[3]
};
AddParsedPoint(child, "start", parts[4], parts[5]);
AddParsedPoint(child, "end", parts[6], parts[7]);
AddParsedPoint(child, "insertion_point", parts[8], parts[9]);
if (TryParseInvariant(parts[10], out var minX) &&
TryParseInvariant(parts[11], out var minY) &&
TryParseInvariant(parts[12], out var maxX) &&
TryParseInvariant(parts[13], out var maxY))
{
child["bbox"] = new BoundingBox(minX, minY, maxX, maxY).ToDictionary();
}
child["layer"] = parts[14];
child["source"] = parts[15];
if (!result.TryGetValue(parent, out var children))
{
children = new List<Dictionary<string, object?>>();
result[parent] = children;
}
children.Add(child);
}
}
catch
{
// Keep base extraction usable if a block cannot be exploded.
}
return result;
}
static string BuildBlockExplosionLisp(string outputPath)
{
var path = LispPath(outputPath);
return string.Join("\n", new[]
{
"(vl-load-com)",
"(defun agent4-num (v) (if (numberp v) (rtos v 2 16) \"\"))",
"(defun agent4-ptx (p) (if (and p (listp p) (numberp (car p))) (rtos (car p) 2 16) \"\"))",
"(defun agent4-pty (p) (if (and p (listp p) (numberp (cadr p))) (rtos (cadr p) 2 16) \"\"))",
"(defun agent4-clean (s) (if s (vl-string-translate \"\\t\\r\\n\" \" \" s) \"\"))",
"(defun agent4-bbox (obj / mn mx r)",
" (setq r (vl-catch-all-apply 'vla-GetBoundingBox (list obj 'mn 'mx)))",
" (if (vl-catch-all-error-p r)",
" (list \"\" \"\" \"\" \"\")",
" (progn",
" (setq mn (vlax-safearray->list mn))",
" (setq mx (vlax-safearray->list mx))",
" (list (agent4-ptx mn) (agent4-pty mn) (agent4-ptx mx) (agent4-pty mx))",
" )",
" )",
")",
"(defun agent4-prop-point (obj prop / v)",
" (setq v (vl-catch-all-apply 'vlax-get-property (list obj prop)))",
" (if (or (null v) (vl-catch-all-error-p v)) nil (vlax-safearray->list (vlax-variant-value v)))",
")",
"(defun agent4-prop-text (obj / s)",
" (setq s (vl-catch-all-apply 'vlax-get-property (list obj 'TextString)))",
" (if (vl-catch-all-error-p s) \"\" (agent4-clean s))",
")",
"(defun agent4-write-child (f parent idx obj / typ txt sp ep ip bb layer)",
" (setq typ (vla-get-ObjectName obj))",
" (setq txt (agent4-prop-text obj))",
" (setq sp (agent4-prop-point obj 'StartPoint))",
" (setq ep (agent4-prop-point obj 'EndPoint))",
" (setq ip (agent4-prop-point obj 'InsertionPoint))",
" (setq bb (agent4-bbox obj))",
" (setq layer (vl-catch-all-apply 'vlax-get-property (list obj 'Layer)))",
" (if (vl-catch-all-error-p layer) (setq layer \"\"))",
" (write-line",
" (strcat parent \"\\t\" (itoa idx) \"\\t\" typ \"\\t\" txt \"\\t\"",
" (agent4-ptx sp) \"\\t\" (agent4-pty sp) \"\\t\"",
" (agent4-ptx ep) \"\\t\" (agent4-pty ep) \"\\t\"",
" (agent4-ptx ip) \"\\t\" (agent4-pty ip) \"\\t\"",
" (nth 0 bb) \"\\t\" (nth 1 bb) \"\\t\" (nth 2 bb) \"\\t\" (nth 3 bb) \"\\t\"",
" layer \"\\t\" \"vla-explode\")",
" f)",
")",
"(defun agent4-read-block-explosions (/ f ss i ent obj parent arr children child idx)",
$" (setq f (open \"{path}\" \"w\"))",
" (setq ss (ssget \"_X\" '((0 . \"INSERT\"))))",
" (if ss",
" (progn",
" (setq i 0)",
" (while (< i (sslength ss))",
" (setq ent (ssname ss i))",
" (setq obj (vlax-ename->vla-object ent))",
" (setq parent (vla-get-Handle obj))",
" (setq arr (vl-catch-all-apply 'vla-Explode (list obj)))",
" (if (not (vl-catch-all-error-p arr))",
" (progn",
" (setq children (vlax-safearray->list (vlax-variant-value arr)))",
" (setq idx 0)",
" (foreach child children",
" (agent4-write-child f parent idx child)",
" (vl-catch-all-apply 'vla-Delete (list child))",
" (setq idx (1+ idx))",
" )",
" )",
" )",
" (setq i (1+ i))",
" )",
" )",
" )",
" (if f (close f))",
" (princ)",
")",
"(agent4-read-block-explosions)"
}) + "\n";
}
static void AddParsedPoint(Dictionary<string, object?> item, string key, string xText, string yText)
{
if (TryParseInvariant(xText, out var x) && TryParseInvariant(yText, out var y))
item[key] = new List<double> { x, y };
}
static string LispPath(string path) => Path.GetFullPath(path).Replace("\\", "/").Replace("\"", "\\\"");
static bool TryParseInvariant(string text, out double value) =>
double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
static void WaitForQuiescent(object app, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var state = TryInvoke(app, "GetAcadState", []);
if (state == null)
return;
try
{
if (ToBool(GetProperty(state, "IsQuiescent")) ?? true)
return;
}
finally
{
ReleaseCom(state);
}
Thread.Sleep(250);
}
}
static DeleteResult SaveMainViewOnlyDwg(object doc, string savePath, HashSet<string> keepHandles)
{
Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? Environment.CurrentDirectory);
var deleted = 0;
deleted += DeleteEntitiesNotInHandles(doc, "ModelSpace", keepHandles);
deleted += DeleteEntitiesNotInHandles(doc, "PaperSpace", keepHandles);
TryInvoke(doc, "SaveAs", [savePath]);
return new DeleteResult(deleted);
}
static int DeleteEntitiesNotInHandles(object doc, string spaceName, HashSet<string> keepHandles)
{
var deleted = 0;
var space = GetProperty(doc, spaceName);
if (space == null)
return deleted;
try
{
var count = ToInt(GetProperty(space, "Count")) ?? 0;
for (var i = count - 1; i >= 0; i--)
{
var entity = GetCollectionItem(space, i);
if (entity == null)
continue;
try
{
var handle = ReadString(entity, "Handle");
if (!keepHandles.Contains(handle))
{
TryInvoke(entity, "Delete", []);
deleted++;
}
}
finally
{
ReleaseCom(entity);
}
}
}
finally
{
ReleaseCom(space);
}
return deleted;
}
static object? GetProperty(object obj, string name) => RetryCom(() =>
{
try
{
return obj.GetType().InvokeMember(name, BindingFlags.GetProperty, null, obj, null, CultureInfo.InvariantCulture);
}
catch (TargetInvocationException ex) when (IsRetryableCom(ex.InnerException))
{
throw;
}
catch (COMException ex) when (IsRetryableCom(ex))
{
throw;
}
catch
{
return null;
}
});
static object? GetCollectionItem(object obj, int index) => RetryCom(() =>
{
foreach (var flags in new[] { BindingFlags.GetProperty, BindingFlags.InvokeMethod })
{
try
{
return obj.GetType().InvokeMember("Item", flags, null, obj, [index], CultureInfo.InvariantCulture);
}
catch (TargetInvocationException ex) when (IsRetryableCom(ex.InnerException))
{
throw;
}
catch (COMException ex) when (IsRetryableCom(ex))
{
throw;
}
catch
{
// Try next dispatch style.
}
}
return null;
});
static object? TryInvoke(object obj, string name, object?[] args) => RetryCom(() =>
{
try
{
return obj.GetType().InvokeMember(name, BindingFlags.InvokeMethod, null, obj, args, CultureInfo.InvariantCulture);
}
catch (TargetInvocationException ex) when (IsRetryableCom(ex.InnerException))
{
throw;
}
catch (COMException ex) when (IsRetryableCom(ex))
{
throw;
}
catch
{
return null;
}
});
static void TrySetProperty(object obj, string name, object? value)
{
RetryCom(() =>
{
try
{
obj.GetType().InvokeMember(name, BindingFlags.SetProperty, null, obj, [value], CultureInfo.InvariantCulture);
}
catch
{
// Optional.
}
return null;
});
}
static object? RetryCom(Func<object?> action)
{
Exception? last = null;
for (var i = 0; i < 180; i++)
{
try
{
return action();
}
catch (TargetInvocationException ex) when (IsRetryableCom(ex.InnerException))
{
last = ex.InnerException;
Thread.Sleep(100);
}
catch (COMException ex) when (IsRetryableCom(ex))
{
last = ex;
Thread.Sleep(100);
}
}
if (last != null)
throw last;
return action();
}
static bool IsRetryableCom(Exception? ex)
{
return ex is COMException com && unchecked((uint)com.HResult) is 0x80010001 or 0x8001010A;
}
static bool IsGeometry(string objectName)
{
return objectName.Contains("Line", StringComparison.OrdinalIgnoreCase) ||
objectName.Contains("Circle", StringComparison.OrdinalIgnoreCase) ||
objectName.Contains("Arc", StringComparison.OrdinalIgnoreCase) ||
objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase) ||
objectName.Contains("Hatch", StringComparison.OrdinalIgnoreCase) ||
objectName.Contains("Wipeout", StringComparison.OrdinalIgnoreCase) ||
objectName.Contains("RasterImage", StringComparison.OrdinalIgnoreCase);
}
static bool IsPolyline(string objectName)
{
return objectName.Contains("Polyline", StringComparison.OrdinalIgnoreCase);
}
static bool IsAnnotation(string objectName)
{
return objectName.Contains("Text", StringComparison.OrdinalIgnoreCase) ||
objectName.Contains("BlockReference", StringComparison.OrdinalIgnoreCase);
}
static bool LooksLikeRoughness(string text, string blockName)
{
var merged = text + " " + blockName;
return Regex.IsMatch(merged, @"\bR[az]\s*\d", RegexOptions.IgnoreCase) ||
merged.Contains("粗糙", StringComparison.OrdinalIgnoreCase) ||
merged.Contains("其余", StringComparison.OrdinalIgnoreCase) ||
merged.Contains("rough", StringComparison.OrdinalIgnoreCase);
}
static bool LooksLikeGeometricTolerance(string text, string blockName)
{
var merged = text + " " + blockName;
return merged.Contains("位置度", StringComparison.OrdinalIgnoreCase) ||
merged.Contains("同轴度", StringComparison.OrdinalIgnoreCase) ||
merged.Contains("平行度", StringComparison.OrdinalIgnoreCase) ||
merged.Contains("垂直度", StringComparison.OrdinalIgnoreCase) ||
merged.Contains("圆跳动", StringComparison.OrdinalIgnoreCase) ||
merged.Contains("geotol", StringComparison.OrdinalIgnoreCase) ||
Regex.IsMatch(merged, @"[⊥∥⌖◎⌯⌭]");
}
static bool LooksLikeDatum(string text, string blockName)
{
var merged = text + " " + blockName;
return merged.Contains("基准", StringComparison.OrdinalIgnoreCase) ||
merged.Contains("datum", StringComparison.OrdinalIgnoreCase);
}
static List<double> ExtractNumbers(string text)
{
return Regex.Matches(text, @"[+-]?\d+(?:\.\d+)?")
.Select(match => double.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : (double?)null)
.Where(value => value != null)
.Select(value => value!.Value)
.ToList();
}
static string CleanAcadText(string text)
{
if (string.IsNullOrWhiteSpace(text))
return "";
var value = text.Replace("%%C", "Φ", StringComparison.OrdinalIgnoreCase)
.Replace("%%D", "°", StringComparison.OrdinalIgnoreCase)
.Replace("%%P", "±", StringComparison.OrdinalIgnoreCase);
value = Regex.Replace(value, @"\\A\d+;", "");
value = Regex.Replace(value, @"\\H[^;]+;", "");
value = Regex.Replace(value, @"\\S([^;]+)\^([^;]*);", "$1/$2");
value = Regex.Replace(value, @"\\S([^;]+);", "$1");
value = Regex.Replace(value, @"[{}]", "");
value = value.Replace(@"\P", " ");
return Regex.Replace(value, @"\s+", " ").Trim();
}
static void AddPoint(Dictionary<string, object?> details, string name, object? value)
{
var point = DoubleList(value);
if (point.Count > 0)
details[name] = point;
}
static void AddPointFromProperties(
Dictionary<string, object?> details,
string name,
object entity,
List<string> missingFields,
params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
var point = DoubleList(GetProperty(entity, propertyName));
if (point.Count == 0)
continue;
details[name] = point;
details[$"{name}_source_property"] = propertyName;
return;
}
missingFields.Add(name);
}
static void ApplyDimensionDefinitionPoints(Dictionary<string, object?> details, DimensionDefinitionPoints definitionPoints)
{
ApplyDimensionDefinitionPoint(details, "xline1_point", "dxf_group_13", definitionPoints.Group13);
ApplyDimensionDefinitionPoint(details, "xline2_point", "dxf_group_14", definitionPoints.Group14);
ApplyDimensionDefinitionPoint(details, "dim_line_point", "dxf_group_10", definitionPoints.Group10);
ApplyDimensionDefinitionPoint(details, "text_position", "dxf_group_11", definitionPoints.Group11);
ApplyDimensionDefinitionPoint(details, "center", "dxf_group_15", definitionPoints.Group15);
ApplyDimensionDefinitionPoint(details, "chord_point", "dxf_group_16", definitionPoints.Group16);
if (details.TryGetValue("dimension_point_missing_fields", out var value) && value is List<string> missing)
{
missing.RemoveAll(field => details.ContainsKey(field));
if (missing.Count == 0)
details.Remove("dimension_point_missing_fields");
}
}
static void ApplyDimensionDefinitionPoint(
Dictionary<string, object?> details,
string field,
string source,
List<double>? point)
{
if (point is not { Count: >= 2 })
return;
details[field] = point;
details[$"{field}_source_property"] = source;
}
static void EnsureSplineEndpoint(
Dictionary<string, object?> details,
string name,
List<double> fitPoints,
List<double> controlPoints,
bool takeFirst)
{
if (details.TryGetValue(name, out var value) && DoubleList(value).Count >= 2)
return;
var point = EndpointFromFlatPoints(fitPoints, takeFirst);
if (point.Count < 2)
point = EndpointFromFlatPoints(controlPoints, takeFirst);
if (point.Count >= 2)
details[name] = point;
}
static List<double> EndpointFromFlatPoints(List<double> values, bool takeFirst)
{
if (values.Count < 2)
return new List<double>();
var stride = values.Count % 3 == 0 ? 3 : 2;
if (values.Count < stride)
return new List<double>();
var index = takeFirst ? 0 : values.Count - stride;
return new List<double> { values[index], values[index + 1] };
}
static List<double> DoubleList(object? value)
{
if (value == null)
return new List<double>();
if (value is List<double> list)
return list;
if (value is double[] doubles)
return doubles.ToList();
if (value is object[] objects)
return objects.Select(ToDouble).Where(item => item != null).Select(item => item!.Value).ToList();
if (value is Array array)
{
var result = new List<double>();
foreach (var item in array)
{
if (ToDouble(item) is { } number)
result.Add(number);
}
return result;
}
return new List<double>();
}
static List<double> ReadPolylineBulges(object entity, int coordinateCount)
{
var result = new List<double>();
var vertexCount = coordinateCount % 3 == 0 ? coordinateCount / 3 : coordinateCount / 2;
for (var i = 0; i < Math.Max(0, vertexCount); i++)
{
var bulge = ToDouble(TryInvoke(entity, "GetBulge", [i])) ?? 0.0;
result.Add(bulge);
}
return result;
}
static Dictionary<string, object?> Merge(Dictionary<string, object?> common, Dictionary<string, object?> details)
{
var result = new Dictionary<string, object?>(common, StringComparer.OrdinalIgnoreCase);
foreach (var item in details)
result[item.Key] = item.Value;
return result;
}
static string ReadString(object obj, string name) => Convert.ToString(GetProperty(obj, name), CultureInfo.InvariantCulture) ?? "";
static int? ToInt(object? value)
{
try { return value == null ? null : Convert.ToInt32(value, CultureInfo.InvariantCulture); }
catch { return null; }
}
static double? ToDouble(object? value)
{
try { return value == null ? null : Convert.ToDouble(value, CultureInfo.InvariantCulture); }
catch { return null; }
}
static bool? ToBool(object? value)
{
try { return value == null ? null : Convert.ToBoolean(value, CultureInfo.InvariantCulture); }
catch { return null; }
}
static void ReleaseCom(object obj)
{
try
{
if (Marshal.IsComObject(obj))
Marshal.FinalReleaseComObject(obj);
}
catch
{
// Best effort.
}
}
static object? TryGetActiveComObject(string progId)
{
try
{
var hr = CLSIDFromProgID(progId, out var clsid);
if (hr < 0)
Marshal.ThrowExceptionForHR(hr);
return GetActiveObject(ref clsid, IntPtr.Zero);
}
catch
{
return null;
}
}
[DllImport("ole32.dll", CharSet = CharSet.Unicode)]
static extern int CLSIDFromProgID(string progId, out Guid clsid);
[DllImport("oleaut32.dll", PreserveSig = false)]
[return: MarshalAs(UnmanagedType.Interface)]
static extern object GetActiveObject(ref Guid clsid, IntPtr reserved);
}
sealed record SectionSymbol(
string Label,
string MatchedText,
string FullText,
BoundingBox Box,
Dictionary<string, object?> Annotation)
{
public Dictionary<string, object?> ToDictionary()
{
return new Dictionary<string, object?>
{
["label"] = Label,
["matched_text"] = MatchedText,
["full_text"] = FullText,
["bbox"] = Box.ToDictionary(),
["handle"] = Annotation.TryGetValue("handle", out var handle) ? handle : null,
["object_name"] = Annotation.TryGetValue("object_name", out var objectName) ? objectName : null,
["layer"] = Annotation.TryGetValue("layer", out var layer) ? layer : null
};
}
}
sealed record ViewRegion(HashSet<PrimitiveNode> Nodes, BoundingBox Box, List<string> Handles);
sealed class CliOptions
{
public string DrawingPath { get; set; } = "";
public string OutputDir { get; set; } = Path.Combine(Environment.CurrentDirectory, "runtime", "dwg-draft", "latest-extraction");
public string ProgId { get; set; } = "AutoCAD.Application.23.1";
public bool Visible { get; set; } = true;
public bool CloseAfterExtract { get; set; }
public int MaxEntities { get; set; } = 100_000;
public string MainViewSeedMode { get; set; } = "upper-left";
public string MainViewReferenceSelectionPath { get; set; } = "";
public string SaveMainViewOnlyDwgPath { get; set; } = "";
public string SectionViewProbeDirection { get; set; } = "down";
public string ReuseExtractionDir { get; set; } = "";
public bool AllowStartAutoCad { get; set; }
public static CliOptions Parse(string[] args)
{
var options = new CliOptions();
for (var i = 0; i < args.Length; i++)
{
var key = args[i];
var value = i + 1 < args.Length ? args[i + 1] : "";
switch (key)
{
case "--drawing-path":
options.DrawingPath = Path.GetFullPath(value.Trim().Trim('"'));
i++;
break;
case "--output-dir":
options.OutputDir = Path.GetFullPath(value.Trim().Trim('"'));
i++;
break;
case "--prog-id":
options.ProgId = value;
i++;
break;
case "--visible":
options.Visible = bool.TryParse(value, out var visible) ? visible : options.Visible;
i++;
break;
case "--close-after-extract":
options.CloseAfterExtract = bool.TryParse(value, out var close) && close;
i++;
break;
case "--max-entities":
options.MaxEntities = int.TryParse(value, out var max) ? max : options.MaxEntities;
i++;
break;
case "--main-view-seed-mode":
options.MainViewSeedMode = value.Trim();
i++;
break;
case "--main-view-reference-selection":
options.MainViewReferenceSelectionPath = Path.GetFullPath(value.Trim().Trim('"'));
i++;
break;
case "--save-main-view-only-dwg-path":
options.SaveMainViewOnlyDwgPath = Path.GetFullPath(value.Trim().Trim('"'));
i++;
break;
case "--section-view-probe-direction":
options.SectionViewProbeDirection = NormalizeSectionViewProbeDirection(value);
i++;
break;
case "--reuse-extraction-dir":
options.ReuseExtractionDir = Path.GetFullPath(value.Trim().Trim('"'));
i++;
break;
case "--allow-start-autocad":
options.AllowStartAutoCad = bool.TryParse(value, out var allowStart) && allowStart;
i++;
break;
}
}
return options;
}
static string NormalizeSectionViewProbeDirection(string value)
{
var direction = value.Trim();
if (string.Equals(direction, "up", StringComparison.OrdinalIgnoreCase))
return "up";
if (string.Equals(direction, "down", StringComparison.OrdinalIgnoreCase))
return "down";
throw new ArgumentException($"--section-view-probe-direction only supports up/down: {value}");
}
}
readonly record struct DeleteResult(int DeletedCount);
sealed record SplineEndpoint(List<double> Start, List<double> End, string Source);
sealed record DimensionDefinitionPoints(
List<double>? Group13,
List<double>? Group14,
List<double>? Group10,
List<double>? Group11,
List<double>? Group15,
List<double>? Group16);
static class StaRunner
{
public static T Run<T>(Func<T> action)
{
T? result = default;
Exception? error = null;
var thread = new Thread(() =>
{
try { result = action(); }
catch (Exception ex) { error = ex; }
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (error != null)
throw error;
return result!;
}
}
static class JsonDefaults
{
public static JsonSerializerOptions Options { get; } = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
};
}
sealed class DocumentExtraction
{
public int ModelSpaceCount { get; set; }
public int PaperSpaceCount { get; set; }
public Dictionary<string, int> TypeCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<Dictionary<string, object?>> EntitiesCommon { get; set; } = new();
public List<Dictionary<string, object?>> Geometry { get; set; } = new();
public List<Dictionary<string, object?>> NormalizedGeometry { get; set; } = new();
public List<Dictionary<string, object?>> MergedLines { get; set; } = new();
public List<Dictionary<string, object?>> GeometryOverlaps { get; set; } = new();
public Dictionary<string, object?> NormalizationSummary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<Dictionary<string, object?>> MainViewGeometry { get; set; } = new();
public List<Dictionary<string, object?>> MainViewNormalizedGeometry { get; set; } = new();
public List<Dictionary<string, object?>> MainViewMergedLines { get; set; } = new();
public List<Dictionary<string, object?>> MainViewOverlaps { get; set; } = new();
public Dictionary<string, object?> MainViewSignature { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, object?> BottomViewSignature { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<Dictionary<string, object?>> SectionCutSymbols { get; set; } = new();
public Dictionary<string, object?> MainViewSelection { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, object?> ViewRegions { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<Dictionary<string, object?>> Annotations { get; set; } = new();
public List<Dictionary<string, object?>> Dimensions { get; set; } = new();
public List<Dictionary<string, object?>> DimensionalTolerances { get; set; } = new();
public List<Dictionary<string, object?>> Roughness { get; set; } = new();
public List<Dictionary<string, object?>> GeometricTolerances { get; set; } = new();
public List<Dictionary<string, object?>> Datums { get; set; } = new();
public List<Dictionary<string, object?>> UnknownEntities { get; set; } = new();
}
sealed class ExtractionResult
{
public bool Ok { get; set; }
public string Source { get; set; } = "";
public string ProgId { get; set; } = "";
public bool Started { get; set; }
public string DrawingPath { get; set; } = "";
public string ExportDir { get; set; } = "";
public string ManifestPath { get; set; } = "";
public int EntityCount { get; set; }
public Dictionary<string, int> TypeCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<CategoryFile> CategoryFiles { get; set; } = new();
public string Message { get; set; } = "";
}
sealed class Manifest
{
public bool Ok { get; set; }
public string Schema { get; set; } = "";
public string Source { get; set; } = "";
public string ProgId { get; set; } = "";
public bool StartedAutoCad { get; set; }
public string DrawingPath { get; set; } = "";
public DateTime ExportedAtUtc { get; set; }
public int ModelSpaceCount { get; set; }
public int PaperSpaceCount { get; set; }
public int EntityCount { get; set; }
public Dictionary<string, int> TypeCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<CategoryFile> CategoryFiles { get; set; } = new();
}
sealed class CategoryFile
{
public string Category { get; set; } = "";
public string File { get; set; } = "";
public string FullPath { get; set; } = "";
public int ItemCount { get; set; }
public bool Exists { get; set; }
}
sealed class LinePrimitive
{
public int Index { get; init; }
public string Handle { get; init; } = "";
public string Layer { get; init; } = "";
public string Linetype { get; init; } = "";
public int? Color { get; init; }
public int? Lineweight { get; init; }
public string StyleKey { get; init; } = "";
public Dictionary<string, object?> Style { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public List<string> SourceHandles { get; init; } = new();
public double StartX { get; init; }
public double StartY { get; init; }
public double EndX { get; init; }
public double EndY { get; init; }
public double Ux { get; init; }
public double Uy { get; init; }
public double Nx { get; init; }
public double Ny { get; init; }
public double T0 { get; init; }
public double T1 { get; init; }
public double Offset { get; init; }
public double Length { get; init; }
public double AngleDeg { get; init; }
public static LinePrimitive? FromDictionary(Dictionary<string, object?> item)
{
var start = ReadPoint(item, "start");
var end = ReadPoint(item, "end");
if (start.Count < 2 || end.Count < 2)
return null;
var dx = end[0] - start[0];
var dy = end[1] - start[1];
var length = Math.Sqrt(dx * dx + dy * dy);
if (length <= 1e-9)
return null;
var ux = dx / length;
var uy = dy / length;
var nx = -uy;
var ny = ux;
var angle = NormalizeAngleDeg(Math.Atan2(dy, dx) * 180.0 / Math.PI);
var handles = ReadStringList(item, "source_handles");
var handle = ReadString(item, "handle");
if (handles.Count == 0 && !string.IsNullOrWhiteSpace(handle))
handles.Add(handle);
return new LinePrimitive
{
Index = ReadInt(item, "index") ?? 0,
Handle = handle,
Layer = ReadString(item, "layer"),
Linetype = ReadString(item, "linetype"),
Color = ReadInt(item, "color"),
Lineweight = ReadInt(item, "lineweight"),
StyleKey = ReadString(item, "style_key"),
Style = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["space"] = ReadString(item, "space"),
["layer"] = ReadString(item, "layer"),
["linetype"] = ReadString(item, "linetype"),
["color"] = ReadInt(item, "color"),
["lineweight"] = ReadInt(item, "lineweight")
},
SourceHandles = handles,
StartX = start[0],
StartY = start[1],
EndX = end[0],
EndY = end[1],
Ux = ux,
Uy = uy,
Nx = nx,
Ny = ny,
T0 = start[0] * ux + start[1] * uy,
T1 = end[0] * ux + end[1] * uy,
Offset = start[0] * nx + start[1] * ny,
Length = length,
AngleDeg = angle
};
}
public double SignedDistanceToLine(double x, double y) => x * Nx + y * Ny - Offset;
public List<double> PointAt(double t)
{
return new List<double> { Ux * t + Nx * Offset, Uy * t + Ny * Offset };
}
static double NormalizeAngleDeg(double angle)
{
angle %= 180.0;
if (angle < 0)
angle += 180.0;
return angle;
}
static List<double> ReadPoint(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value is not IEnumerable<object> objects)
{
if (value is IEnumerable<double> doubles)
return doubles.ToList();
return new List<double>();
}
return objects
.Select(value => value == null ? (double?)null : Convert.ToDouble(value, CultureInfo.InvariantCulture))
.Where(value => value != null)
.Select(value => value!.Value)
.ToList();
}
static string ReadString(Dictionary<string, object?> item, string key)
{
return item.TryGetValue(key, out var value) ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? "" : "";
}
static int? ReadInt(Dictionary<string, object?> item, string key)
{
try
{
return item.TryGetValue(key, out var value) && value != null
? Convert.ToInt32(value, CultureInfo.InvariantCulture)
: null;
}
catch
{
return null;
}
}
static List<string> ReadStringList(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
return new List<string>();
if (value is IEnumerable<string> strings)
return strings.Where(text => !string.IsNullOrWhiteSpace(text)).ToList();
if (value is IEnumerable<object> objects)
return objects.Select(obj => Convert.ToString(obj, CultureInfo.InvariantCulture) ?? "").Where(text => !string.IsNullOrWhiteSpace(text)).ToList();
return new List<string>();
}
}
sealed record LineInterval(LinePrimitive Line, double Start, double End);
sealed record NormalizedGeometrySet(
List<Dictionary<string, object?>> Normalized,
List<Dictionary<string, object?>> MergedLines,
List<Dictionary<string, object?>> Overlaps,
Dictionary<string, object?> Summary);
sealed class PrimitiveNode
{
public string Source { get; init; } = "";
public string Kind { get; init; } = "";
public Dictionary<string, object?> Data { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public BoundingBox Box { get; init; }
public List<string> SourceHandles { get; init; } = new();
public List<List<double>> AnchorPoints { get; init; } = new();
public static PrimitiveNode? FromRawGeometry(Dictionary<string, object?> item)
{
var objectName = ReadString(item, "object_name");
var handle = ReadString(item, "handle");
var kind = RawKind(objectName);
var box = TryReadRawBox(item, kind);
if (box == null)
return null;
return new PrimitiveNode
{
Source = "raw-geometry",
Kind = kind,
Data = item,
Box = box.Value,
SourceHandles = string.IsNullOrWhiteSpace(handle) ? new List<string>() : new List<string> { handle },
AnchorPoints = ReadRawAnchorPoints(item, kind, box.Value)
};
}
public static PrimitiveNode? FromDictionary(Dictionary<string, object?> item)
{
var kind = ReadString(item, "kind");
var box = TryReadBox(item, kind);
if (box == null)
return null;
return new PrimitiveNode
{
Source = item.ContainsKey("merge_type") ? "merged-line" : "normalized-geometry",
Kind = kind,
Data = item,
Box = box.Value,
SourceHandles = ReadStringList(item, "source_handles"),
AnchorPoints = ReadAnchorPoints(item, kind)
};
}
static string RawKind(string objectName)
{
if (objectName.Contains("Polyline", StringComparison.OrdinalIgnoreCase))
return "polyline";
if (objectName.Contains("Circle", StringComparison.OrdinalIgnoreCase))
return "circle";
if (objectName.Contains("Arc", StringComparison.OrdinalIgnoreCase))
return "arc";
if (objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase))
return "spline";
if (objectName.Contains("Line", StringComparison.OrdinalIgnoreCase))
return "line";
if (objectName.Contains("Hatch", StringComparison.OrdinalIgnoreCase))
return "hatch";
return "geometry";
}
static BoundingBox? TryReadRawBox(Dictionary<string, object?> item, string kind)
{
if (TryReadEntityBox(item, "bbox") is { } entityBox)
return entityBox;
if (string.Equals(kind, "line", StringComparison.OrdinalIgnoreCase))
{
var start = ReadPoint(item, "start");
var end = ReadPoint(item, "end");
return BoundingBox.FromPoints(new[] { start, end });
}
if (string.Equals(kind, "polyline", StringComparison.OrdinalIgnoreCase))
{
var coordinates = ReadDoubleList(item, "coordinates");
var stride = coordinates.Count % 3 == 0 ? 3 : 2;
var points = new List<List<double>>();
for (var i = 0; i + stride - 1 < coordinates.Count; i += stride)
points.Add(new List<double> { coordinates[i], coordinates[i + 1] });
return BoundingBox.FromPoints(points);
}
if (string.Equals(kind, "circle", StringComparison.OrdinalIgnoreCase) ||
string.Equals(kind, "arc", StringComparison.OrdinalIgnoreCase))
{
var center = ReadPoint(item, "center");
var radius = ReadDouble(item, "radius");
if (center.Count < 2 || radius == null)
return BoundingBox.FromPoints(new[] { ReadPoint(item, "start"), ReadPoint(item, "end") });
return new BoundingBox(center[0] - radius.Value, center[1] - radius.Value, center[0] + radius.Value, center[1] + radius.Value);
}
if (string.Equals(kind, "spline", StringComparison.OrdinalIgnoreCase))
{
return BoundingBox.FromPoints(new[] { ReadPoint(item, "start"), ReadPoint(item, "end") });
}
return null;
}
static BoundingBox? TryReadEntityBox(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
return null;
if (value is Dictionary<string, object?> dict)
{
var minX = ReadDouble(dict, "min_x");
var minY = ReadDouble(dict, "min_y");
var maxX = ReadDouble(dict, "max_x");
var maxY = ReadDouble(dict, "max_y");
if (minX != null && minY != null && maxX != null && maxY != null)
return new BoundingBox(minX.Value, minY.Value, maxX.Value, maxY.Value);
}
return null;
}
static List<List<double>> ReadRawAnchorPoints(Dictionary<string, object?> item, string kind, BoundingBox box)
{
var points = new List<List<double>>();
if (string.Equals(kind, "polyline", StringComparison.OrdinalIgnoreCase))
{
var coordinates = ReadDoubleList(item, "coordinates");
var stride = coordinates.Count % 3 == 0 ? 3 : 2;
for (var i = 0; i + stride - 1 < coordinates.Count; i += stride)
points.Add(new List<double> { coordinates[i], coordinates[i + 1] });
}
else
{
foreach (var key in new[] { "start", "end", "center" })
{
var point = ReadPoint(item, key);
if (point.Count >= 2)
points.Add(point);
}
}
if (points.Count == 0)
points.Add(new List<double> { box.CenterX, box.CenterY });
return points;
}
static BoundingBox? TryReadBox(Dictionary<string, object?> item, string kind)
{
if (string.Equals(kind, "line", StringComparison.OrdinalIgnoreCase))
{
var start = ReadPoint(item, "start");
var end = ReadPoint(item, "end");
return BoundingBox.FromPoints(new[] { start, end });
}
if (string.Equals(kind, "circle", StringComparison.OrdinalIgnoreCase))
{
var center = ReadPoint(item, "center");
var radius = ReadDouble(item, "radius");
if (center.Count < 2 || radius == null)
return null;
return new BoundingBox(center[0] - radius.Value, center[1] - radius.Value, center[0] + radius.Value, center[1] + radius.Value);
}
if (string.Equals(kind, "arc", StringComparison.OrdinalIgnoreCase))
{
var points = new List<List<double>>
{
ReadPoint(item, "start"),
ReadPoint(item, "end")
}.Where(point => point.Count >= 2).ToList();
var center = ReadPoint(item, "center");
var radius = ReadDouble(item, "radius");
if (center.Count >= 2 && radius != null)
return new BoundingBox(center[0] - radius.Value, center[1] - radius.Value, center[0] + radius.Value, center[1] + radius.Value);
return BoundingBox.FromPoints(points);
}
if (string.Equals(kind, "spline", StringComparison.OrdinalIgnoreCase))
{
var start = ReadPoint(item, "start");
var end = ReadPoint(item, "end");
return BoundingBox.FromPoints(new[] { start, end });
}
return null;
}
static List<List<double>> ReadAnchorPoints(Dictionary<string, object?> item, string kind)
{
var points = new List<List<double>>();
foreach (var key in new[] { "start", "end", "center" })
{
var point = ReadPoint(item, key);
if (point.Count >= 2)
points.Add(point);
}
if (points.Count == 0)
{
var box = TryReadBox(item, kind);
if (box != null)
points.Add(new List<double> { box.Value.CenterX, box.Value.CenterY });
}
return points;
}
static List<double> ReadPoint(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
return new List<double>();
if (value is IEnumerable<double> doubles)
return doubles.ToList();
if (value is IEnumerable<object> objects)
{
return objects
.Select(obj => obj == null ? (double?)null : Convert.ToDouble(obj, CultureInfo.InvariantCulture))
.Where(value => value != null)
.Select(value => value!.Value)
.ToList();
}
return new List<double>();
}
static string ReadString(Dictionary<string, object?> item, string key)
{
return item.TryGetValue(key, out var value) ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? "" : "";
}
static double? ReadDouble(Dictionary<string, object?> item, string key)
{
try
{
return item.TryGetValue(key, out var value) && value != null
? Convert.ToDouble(value, CultureInfo.InvariantCulture)
: null;
}
catch
{
return null;
}
}
static List<double> ReadDoubleList(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
return new List<double>();
if (value is IEnumerable<double> doubles)
return doubles.ToList();
if (value is IEnumerable<object> objects)
{
return objects
.Select(obj => obj == null ? (double?)null : Convert.ToDouble(obj, CultureInfo.InvariantCulture))
.Where(value => value != null)
.Select(value => value!.Value)
.ToList();
}
return new List<double>();
}
static List<string> ReadStringList(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
return new List<string>();
if (value is IEnumerable<string> strings)
return strings.Where(text => !string.IsNullOrWhiteSpace(text)).ToList();
if (value is IEnumerable<object> objects)
return objects.Select(obj => Convert.ToString(obj, CultureInfo.InvariantCulture) ?? "").Where(text => !string.IsNullOrWhiteSpace(text)).ToList();
return new List<string>();
}
}
readonly record struct BoundingBox(double MinX, double MinY, double MaxX, double MaxY)
{
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
public double CenterX => (MinX + MaxX) / 2.0;
public double CenterY => (MinY + MaxY) / 2.0;
public static BoundingBox? FromPoints(IEnumerable<List<double>> points)
{
var valid = points.Where(point => point.Count >= 2).ToList();
if (valid.Count == 0)
return null;
return new BoundingBox(
valid.Min(point => point[0]),
valid.Min(point => point[1]),
valid.Max(point => point[0]),
valid.Max(point => point[1]));
}
public static BoundingBox Union(IEnumerable<BoundingBox> boxes)
{
var list = boxes.ToList();
if (list.Count == 0)
return new BoundingBox(0, 0, 0, 0);
return new BoundingBox(
list.Min(box => box.MinX),
list.Min(box => box.MinY),
list.Max(box => box.MaxX),
list.Max(box => box.MaxY));
}
public BoundingBox Expand(double value) => new(MinX - value, MinY - value, MaxX + value, MaxY + value);
public bool Contains(double x, double y) => x >= MinX && x <= MaxX && y >= MinY && y <= MaxY;
public bool ContainsBox(BoundingBox other) =>
other.MinX >= MinX &&
other.MaxX <= MaxX &&
other.MinY >= MinY &&
other.MaxY <= MaxY;
public double DistanceTo(BoundingBox other)
{
var dx = Math.Max(0.0, Math.Max(other.MinX - MaxX, MinX - other.MaxX));
var dy = Math.Max(0.0, Math.Max(other.MinY - MaxY, MinY - other.MaxY));
return Math.Sqrt(dx * dx + dy * dy);
}
public Dictionary<string, object?> ToDictionary()
{
return new Dictionary<string, object?>
{
["min_x"] = MinX,
["min_y"] = MinY,
["max_x"] = MaxX,
["max_y"] = MaxY,
["center_x"] = CenterX,
["center_y"] = CenterY
};
}
}