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