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 { ["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 splineEndpoints, Dictionary>> blockExplosions, Dictionary 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 splineEndpoints, Dictionary>> blockExplosions, Dictionary 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(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 ExtractDetails(object entity, string objectName) { var details = new Dictionary(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(); 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? 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 { ["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> ReadBlockAttributes(object entity) { var result = new List>(); 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 { ["tag"] = ReadString(item, "TagString"), ["value"] = CleanAcadText(ReadString(item, "TextString")), ["raw_value"] = ReadString(item, "TextString") }); ReleaseCom(item); } return result; } static Dictionary? ExtractDimensionalTolerance(Dictionary common, Dictionary 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> geometry, string scope) { var primitives = new List>(); 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() .ToList(); var mergedLines = MergeStrongLines(lines); var overlaps = FindStyleDifferentOverlaps(lines); var summary = new Dictionary { ["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 entity, List> 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 { ["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 { ["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 { ["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 { ["pattern_name"] = ReadDictString(entity, "pattern_name"), ["pattern_scale"] = ReadDictDouble(entity, "pattern_scale") })); } } static void AddPolylinePrimitives(Dictionary entity, List> output) { var coordinates = ReadDoubleList(entity, "coordinates"); if (coordinates.Count < 4) return; var stride = coordinates.Count % 3 == 0 ? 3 : 2; var points = new List>(); for (var i = 0; i + stride - 1 < coordinates.Count; i += stride) points.Add(new List { 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 { ["start"] = start, ["end"] = end, ["bulge"] = bulge, ["segment_index"] = i + 1, ["source_kind"] = "polyline_bulge_segment" })); } } } static void AddLinePrimitive( Dictionary entity, List> output, List start, List 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 { ["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> MergeStrongLines(List lines) { var result = new List>(); 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> FindStyleDifferentOverlaps(List lines) { var result = new List>(); 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(); 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 { ["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> BuildCollinearClusters(List lines) { var clusters = new List>(); foreach (var line in lines.OrderBy(item => item.Offset)) { List? target = null; foreach (var cluster in clusters) { if (IsCollinear(cluster[0], line)) { target = cluster; break; } } if (target == null) { target = new List(); clusters.Add(target); } target.Add(line); } return clusters; } static List> MergeClusterIntervals(List cluster) { var result = new List>(); 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 { 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 { ["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 { interval.Line }; } Flush(); return result; } static Dictionary BasePrimitive(Dictionary entity, string kind, Dictionary extra) { var handle = ReadDictString(entity, "handle"); var result = new Dictionary(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() : new List { 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 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 PointOnCircle(List center, double radius, double angleRad) { return new List { center[0] + radius * Math.Cos(angleRad), center[1] + radius * Math.Sin(angleRad) }; } static string ReadDictString(Dictionary item, string key) { return item.TryGetValue(key, out var value) ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? "" : ""; } static int? ReadDictInt(Dictionary item, string key) { return item.TryGetValue(key, out var value) ? ToInt(value) : null; } static double? ReadDictDouble(Dictionary item, string key) { return item.TryGetValue(key, out var value) ? ToDouble(value) : null; } static bool? ReadDictBool(Dictionary item, string key) { return item.TryGetValue(key, out var value) ? ToBool(value) : null; } static List ReadPoint(Dictionary item, string key) => ReadDoubleList(item, key); static List ReadDoubleList(Dictionary item, string key) { return item.TryGetValue(key, out var value) ? DoubleList(value) : new List(); } 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 { ["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 { ["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 { ["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(); 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 { ["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 { ["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(); 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 { ["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>(); var unassigned = new HashSet(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 { ["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 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 { ["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 { ["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(); 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 { ["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(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 { ["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 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 { ["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 { ["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>(); var sectionHandles = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var symbol in sectionSymbols) { var seed = FindSectionSeed(symbol, nodes, mainHandles, sectionHandles, options.SectionViewProbeDirection); if (seed == null) { sectionViews.Add(new Dictionary { ["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(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 { ["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>(); 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 dict ? dict : null) .FirstOrDefault(item => item != null) ?? new Dictionary(StringComparer.OrdinalIgnoreCase); extraction.ViewRegions = new Dictionary { ["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> FindSectionCutSymbols( List> annotations, BoundingBox mainBox, BoundingBox? bottomBox) { var result = new List>(); foreach (var block in annotations) { if (!block.TryGetValue("exploded_entities", out var explodedValue) || explodedValue is not IEnumerable explodedObjects) { continue; } var children = explodedObjects .OfType>() .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>() .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 { (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 { cutCenter[0], cutCenter[1] + halfLength } : new List { cutCenter[0] - halfLength, cutCenter[1] }; var cutLineEnd = cutDirection == "vertical" ? new List { cutCenter[0], cutCenter[1] - halfLength } : new List { cutCenter[0] + halfLength, cutCenter[1] }; var viewKind = ClassifySectionCutView(blockBox, mainBox, bottomBox); result.Add(new Dictionary { ["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 { ["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? BuildSectionCutMarker( BoundingBox arrowBox, List> lines, List labelBoxes) { var candidates = new List<(Dictionary 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 Marker, double Score)? BuildSectionCutMarkerForLeaderAxis( BoundingBox arrowBox, List> lines, List labelBoxes, bool leaderIsHorizontal) { var arrowCenter = new List { 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 { ["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 { ["start"] = leaders.Start, ["end"] = leaders.End }, ["section_symbol_line"] = new Dictionary { ["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 Start, List End) FindFarthestPointPair(List> 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 Start, List End)? ReadLine(Dictionary dict, string key) { if (!dict.TryGetValue(key, out var value) || value is not Dictionary 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 region, List> 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 FindSectionSymbols(List> annotations) { var result = new List(); foreach (var annotation in annotations) { var text = ReadDictString(annotation, "text"); if (string.IsNullOrWhiteSpace(text)) continue; var match = Regex.Match( text, @"(? 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 nodes, HashSet mainHandles, HashSet 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 ProbeOrthographicView( string direction, string kind, BoundingBox mainBox, List nodes, HashSet mainHandles, HashSet sectionHandles) { var seed = FindRayHitFromMainView(direction, mainBox, nodes, mainHandles); if (seed == null) { return new Dictionary { ["ok"] = false, ["kind"] = kind, ["direction"] = direction, ["reason"] = "no_ray_hit" }; } if (NodeHasAnyHandle(seed, sectionHandles)) { return new Dictionary { ["ok"] = false, ["kind"] = kind, ["direction"] = direction, ["reason"] = "first_ray_hit_belongs_to_section_view", ["seed"] = NodeToDictionary(seed) }; } var excluded = new HashSet(mainHandles, StringComparer.OrdinalIgnoreCase); excluded.UnionWith(sectionHandles); var region = ExpandRegionFromSeed(seed, nodes, excluded); return new Dictionary { ["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 nodes, HashSet 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 nodes, HashSet 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 handles) { return node.SourceHandles.Any(handle => handles.Contains(handle)); } static Dictionary NodeToDictionary(PrimitiveNode node) { return new Dictionary { ["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 item, string key) { if (!item.TryGetValue(key, out var value) || value == null) return null; if (value is not Dictionary 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 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 FindSectionCutSymbolNodes(List nodes) { var result = new HashSet(); 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 BuildRawGeometryNodes(List> geometry) { var result = new List(); foreach (var entity in geometry) { var node = PrimitiveNode.FromRawGeometry(entity); if (node != null) result.Add(node); } return result; } static HashSet ExpandConnectedNodes(PrimitiveNode seed, List lineNodes) { var selected = new HashSet { seed }; var queue = new Queue(); 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 a0, List a1, List b0, List 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 a0, List a1, List b0, List 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 a, List b, List p) { return (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); } static bool PointOnSegment(List p, List a, List 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 p, List a, List 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 { a[0] + t * dx, a[1] + t * dy }; return Distance(p, proj); } static double Distance(List a, List 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 ReadStringList(Dictionary item, string key) { if (!item.TryGetValue(key, out var value) || value == null) return new List(); if (value is IEnumerable strings) return strings.Where(text => !string.IsNullOrWhiteSpace(text)).ToList(); if (value is IEnumerable objects) return objects.Select(obj => Convert.ToString(obj, CultureInfo.InvariantCulture) ?? "").Where(text => !string.IsNullOrWhiteSpace(text)).ToList(); return new List(); } static Dictionary BuildViewSignature( List> normalizedGeometry, List> 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 { ["scope"] = scope, ["coordinate_policy"] = "relative_origin_from_leftmost_vertical_line_top_point", ["primitive_counts"] = new Dictionary { ["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 BuildRelativeLineCoordinates(List> lines) { var parsedLines = lines .Select(LinePrimitive.FromDictionary) .Where(item => item != null) .Cast() .ToList(); var verticalLines = parsedLines .Where(line => string.Equals(DirectionClass(line.AngleDeg), "vertical", StringComparison.OrdinalIgnoreCase)) .ToList(); if (verticalLines.Count == 0) { return new Dictionary { ["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 { ["ok"] = true, ["origin_rule"] = "leftmost_vertical_line_top_point", ["origin"] = new Dictionary { ["x"] = originX, ["y"] = originY, ["source_handles"] = originLine.SourceHandles, ["line"] = new Dictionary { ["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 { ["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 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 { ["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 Histogram(List values) { var result = new Dictionary(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 BuildLineDirectionCounts(List> lines) { var result = new Dictionary(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 Histogram(List values, double bucketSize) { var result = new Dictionary(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 Stats(List values) { if (values.Count == 0) { return new Dictionary { ["count"] = 0 }; } return new Dictionary { ["count"] = values.Count, ["min"] = values.Min(), ["max"] = values.Max(), ["sum"] = values.Sum(), ["avg"] = values.Average() }; } static double? ArcSweepDeg(Dictionary 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 { 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> ReadJsonListFile(string path) { if (!File.Exists(path)) return new List>(); using var doc = JsonDocument.Parse(File.ReadAllText(path)); if (doc.RootElement.ValueKind != JsonValueKind.Array) return new List>(); var result = new List>(); foreach (var item in doc.RootElement.EnumerateArray()) { if (ConvertJsonValue(item) is Dictionary dict) result.Add(dict); } return result; } static Dictionary ReadTypeCounts(string manifestPath) { var result = new Dictionary(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 ReadSplineEndpointsViaAutoLisp(object app, object doc, string outputDir) { var result = new Dictionary(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 { sx, sy }, new List { 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 ReadDimensionDefinitionPointsViaAutoLisp(object app, object doc, string outputDir) { var result = new Dictionary(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? 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 { x, y, z }; } static Dictionary>> ReadBlockExplosionsViaAutoLisp( object app, object doc, string outputDir) { var result = new Dictionary>>(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(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>(); 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 item, string key, string xText, string yText) { if (TryParseInvariant(xText, out var x) && TryParseInvariant(yText, out var y)) item[key] = new List { 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 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 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 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 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 details, string name, object? value) { var point = DoubleList(value); if (point.Count > 0) details[name] = point; } static void AddPointFromProperties( Dictionary details, string name, object entity, List 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 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 missing) { missing.RemoveAll(field => details.ContainsKey(field)); if (missing.Count == 0) details.Remove("dimension_point_missing_fields"); } } static void ApplyDimensionDefinitionPoint( Dictionary details, string field, string source, List? point) { if (point is not { Count: >= 2 }) return; details[field] = point; details[$"{field}_source_property"] = source; } static void EnsureSplineEndpoint( Dictionary details, string name, List fitPoints, List 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 EndpointFromFlatPoints(List values, bool takeFirst) { if (values.Count < 2) return new List(); var stride = values.Count % 3 == 0 ? 3 : 2; if (values.Count < stride) return new List(); var index = takeFirst ? 0 : values.Count - stride; return new List { values[index], values[index + 1] }; } static List DoubleList(object? value) { if (value == null) return new List(); if (value is List 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(); foreach (var item in array) { if (ToDouble(item) is { } number) result.Add(number); } return result; } return new List(); } static List ReadPolylineBulges(object entity, int coordinateCount) { var result = new List(); 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 Merge(Dictionary common, Dictionary details) { var result = new Dictionary(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 Annotation) { public Dictionary ToDictionary() { return new Dictionary { ["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 Nodes, BoundingBox Box, List 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 Start, List End, string Source); sealed record DimensionDefinitionPoints( List? Group13, List? Group14, List? Group10, List? Group11, List? Group15, List? Group16); static class StaRunner { public static T Run(Func 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 TypeCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase); public List> EntitiesCommon { get; set; } = new(); public List> Geometry { get; set; } = new(); public List> NormalizedGeometry { get; set; } = new(); public List> MergedLines { get; set; } = new(); public List> GeometryOverlaps { get; set; } = new(); public Dictionary NormalizationSummary { get; set; } = new(StringComparer.OrdinalIgnoreCase); public List> MainViewGeometry { get; set; } = new(); public List> MainViewNormalizedGeometry { get; set; } = new(); public List> MainViewMergedLines { get; set; } = new(); public List> MainViewOverlaps { get; set; } = new(); public Dictionary MainViewSignature { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary BottomViewSignature { get; set; } = new(StringComparer.OrdinalIgnoreCase); public List> SectionCutSymbols { get; set; } = new(); public Dictionary MainViewSelection { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary ViewRegions { get; set; } = new(StringComparer.OrdinalIgnoreCase); public List> Annotations { get; set; } = new(); public List> Dimensions { get; set; } = new(); public List> DimensionalTolerances { get; set; } = new(); public List> Roughness { get; set; } = new(); public List> GeometricTolerances { get; set; } = new(); public List> Datums { get; set; } = new(); public List> 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 TypeCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase); public List 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 TypeCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase); public List 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 Style { get; init; } = new(StringComparer.OrdinalIgnoreCase); public List 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 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(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 PointAt(double t) { return new List { 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 ReadPoint(Dictionary item, string key) { if (!item.TryGetValue(key, out var value) || value is not IEnumerable objects) { if (value is IEnumerable doubles) return doubles.ToList(); return new List(); } 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 item, string key) { return item.TryGetValue(key, out var value) ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? "" : ""; } static int? ReadInt(Dictionary item, string key) { try { return item.TryGetValue(key, out var value) && value != null ? Convert.ToInt32(value, CultureInfo.InvariantCulture) : null; } catch { return null; } } static List ReadStringList(Dictionary item, string key) { if (!item.TryGetValue(key, out var value) || value == null) return new List(); if (value is IEnumerable strings) return strings.Where(text => !string.IsNullOrWhiteSpace(text)).ToList(); if (value is IEnumerable objects) return objects.Select(obj => Convert.ToString(obj, CultureInfo.InvariantCulture) ?? "").Where(text => !string.IsNullOrWhiteSpace(text)).ToList(); return new List(); } } sealed record LineInterval(LinePrimitive Line, double Start, double End); sealed record NormalizedGeometrySet( List> Normalized, List> MergedLines, List> Overlaps, Dictionary Summary); sealed class PrimitiveNode { public string Source { get; init; } = ""; public string Kind { get; init; } = ""; public Dictionary Data { get; init; } = new(StringComparer.OrdinalIgnoreCase); public BoundingBox Box { get; init; } public List SourceHandles { get; init; } = new(); public List> AnchorPoints { get; init; } = new(); public static PrimitiveNode? FromRawGeometry(Dictionary 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() : new List { handle }, AnchorPoints = ReadRawAnchorPoints(item, kind, box.Value) }; } public static PrimitiveNode? FromDictionary(Dictionary 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 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>(); for (var i = 0; i + stride - 1 < coordinates.Count; i += stride) points.Add(new List { 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 item, string key) { if (!item.TryGetValue(key, out var value) || value == null) return null; if (value is Dictionary 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> ReadRawAnchorPoints(Dictionary item, string kind, BoundingBox box) { var points = new List>(); 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 { 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 { box.CenterX, box.CenterY }); return points; } static BoundingBox? TryReadBox(Dictionary 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> { 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> ReadAnchorPoints(Dictionary item, string kind) { var points = new List>(); 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 { box.Value.CenterX, box.Value.CenterY }); } return points; } static List ReadPoint(Dictionary item, string key) { if (!item.TryGetValue(key, out var value) || value == null) return new List(); if (value is IEnumerable doubles) return doubles.ToList(); if (value is IEnumerable 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(); } static string ReadString(Dictionary item, string key) { return item.TryGetValue(key, out var value) ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? "" : ""; } static double? ReadDouble(Dictionary item, string key) { try { return item.TryGetValue(key, out var value) && value != null ? Convert.ToDouble(value, CultureInfo.InvariantCulture) : null; } catch { return null; } } static List ReadDoubleList(Dictionary item, string key) { if (!item.TryGetValue(key, out var value) || value == null) return new List(); if (value is IEnumerable doubles) return doubles.ToList(); if (value is IEnumerable 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(); } static List ReadStringList(Dictionary item, string key) { if (!item.TryGetValue(key, out var value) || value == null) return new List(); if (value is IEnumerable strings) return strings.Where(text => !string.IsNullOrWhiteSpace(text)).ToList(); if (value is IEnumerable objects) return objects.Select(obj => Convert.ToString(obj, CultureInfo.InvariantCulture) ?? "").Where(text => !string.IsNullOrWhiteSpace(text)).ToList(); return new List(); } } 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> 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 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 ToDictionary() { return new Dictionary { ["min_x"] = MinX, ["min_y"] = MinY, ["max_x"] = MaxX, ["max_y"] = MaxY, ["center_x"] = CenterX, ["center_y"] = CenterY }; } }