using System.Diagnostics; using System.Globalization; using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using Microsoft.Win32; var options = Options.Parse(args); if (!File.Exists(options.InputJsonl)) throw new FileNotFoundException($"ownership JSONL not found: {options.InputJsonl}"); if (!File.Exists(options.SourceDwg)) throw new FileNotFoundException($"source DWG not found: {options.SourceDwg}"); if (string.IsNullOrWhiteSpace(options.OutputDwg)) throw new ArgumentException("--output-dwg is required."); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(options.OutputDwg))!); File.Copy(options.SourceDwg, options.OutputDwg, overwrite: true); var records = ReadPointRecords(options.InputJsonl).ToList(); Console.OutputEncoding = Encoding.UTF8; if (options.ColorEntities) { var colorResult = ColorDwgEntities(options.OutputDwg, records, options); WriteEntityColorReport(options, records.Count, colorResult); WriteEntityColorLegend(options, records.Count, colorResult); Console.WriteLine($"input_points={records.Count}"); Console.WriteLine($"entity_candidates={colorResult.CandidateCount}"); Console.WriteLine($"colored_entities={colorResult.Colored.Count}"); Console.WriteLine($"skipped_entities={colorResult.SkippedCount}"); Console.WriteLine($"output_dwg={Path.GetFullPath(options.OutputDwg)}"); Console.WriteLine($"report={Path.GetFullPath(options.ReportPath)}"); Console.WriteLine($"legend={Path.GetFullPath(options.LegendPath)}"); foreach (var item in colorResult.Colored.Take(30)) Console.WriteLine($"color={item.ColorIndex} handle={item.Handle} kind={item.ObjectName} sample=({item.SampleX:F3},{item.SampleY:F3}) distance={item.DistanceMm:F3} {item.DisplayName}"); } else { var groups = BuildGroups(records, options).ToList(); var contours = BuildContours(groups, options).ToList(); DrawContours(options.OutputDwg, contours, options); WriteReport(options, records.Count, groups.Count, contours); Console.WriteLine($"input_points={records.Count}"); Console.WriteLine($"groups={groups.Count}"); Console.WriteLine($"contours={contours.Count}"); Console.WriteLine($"output_dwg={Path.GetFullPath(options.OutputDwg)}"); Console.WriteLine($"report={Path.GetFullPath(options.ReportPath)}"); foreach (var contour in contours.Take(30)) { Console.WriteLine( $"color={contour.ColorIndex} pts={contour.PointCount} bbox_mm=({contour.MinX:F3},{contour.MinY:F3},{contour.MaxX:F3},{contour.MaxY:F3}) {contour.DisplayName}"); } } static IEnumerable ReadPointRecords(string path) { foreach (var line in File.ReadLines(path, Encoding.UTF8)) { if (string.IsNullOrWhiteSpace(line)) continue; using var doc = JsonDocument.Parse(line); var root = doc.RootElement; if (!string.Equals(ReadString(root, "Kind", "kind"), "point", StringComparison.OrdinalIgnoreCase)) continue; if (!ReadBool(root, "Hit", "hit")) continue; var ownership = root.TryGetProperty("Ownership", out var pascal) ? pascal : root.TryGetProperty("ownership", out var camel) ? camel : default; if (ownership.ValueKind == JsonValueKind.Undefined) continue; var rawKey = ReadString(ownership, "EntityComponent", "entity_component"); if (string.IsNullOrWhiteSpace(rawKey) || string.Equals(rawKey, "null", StringComparison.OrdinalIgnoreCase)) continue; // Section lines and other drawing annotations can be IEntity but do not // represent a component ownership region. var selectedObject = ReadString(root, "SelectedObject", "selected_object"); if (selectedObject.Contains("DrSectionLine", StringComparison.OrdinalIgnoreCase)) continue; var x = ReadDouble(root, "SheetXMm", "sheet_x_mm", "SheetXMM"); var y = ReadDouble(root, "SheetYMm", "sheet_y_mm", "SheetYMM"); if (!double.IsFinite(x) || !double.IsFinite(y)) continue; var row = ReadInt(root, "Row", "row"); var column = ReadInt(root, "Column", "column"); var key = NormalizeOwnershipKey(rawKey); yield return new PointRecord(key, DisplayNameFromKey(key), x, y, row, column); } } static IEnumerable BuildGroups(List records, Options options) { var colorIndex = 0; foreach (var byOwner in records.GroupBy(r => r.Key, StringComparer.OrdinalIgnoreCase) .OrderByDescending(g => g.Count())) { var points = byOwner.ToList(); if (points.Count < options.MinGroupPoints) continue; var clusters = SplitConnectedClusters(points, options.StepMm * 1.55) .Where(cluster => cluster.Count >= options.MinClusterPoints) .OrderByDescending(cluster => cluster.Count) .ToList(); var ownerColor = options.Colors[colorIndex % options.Colors.Length]; colorIndex++; for (var i = 0; i < clusters.Count; i++) { yield return new ComponentGroup( byOwner.Key, points[0].DisplayName, i + 1, clusters[i], ownerColor); } } } static List> SplitConnectedClusters(List points, double maxDistanceMm) { var clusters = new List>(); var visited = new bool[points.Count]; var maxDistanceSquared = maxDistanceMm * maxDistanceMm; for (var i = 0; i < points.Count; i++) { if (visited[i]) continue; var cluster = new List(); var queue = new Queue(); visited[i] = true; queue.Enqueue(i); while (queue.Count > 0) { var current = queue.Dequeue(); var point = points[current]; cluster.Add(point); for (var j = 0; j < points.Count; j++) { if (visited[j]) continue; var other = points[j]; var dx = point.XMm - other.XMm; var dy = point.YMm - other.YMm; if (dx * dx + dy * dy > maxDistanceSquared) continue; visited[j] = true; queue.Enqueue(j); } } clusters.Add(cluster); } return clusters; } static IEnumerable BuildContours(List groups, Options options) { foreach (var group in groups) { var unique = group.Points .GroupBy(point => (X: Round(point.XMm), Y: Round(point.YMm))) .Select(g => new HullPoint(g.Key.X, g.Key.Y)) .OrderBy(point => point.X) .ThenBy(point => point.Y) .ToList(); var hull = unique.Count >= 3 ? ConvexHull(unique) : unique; var padded = PadPolygonFromCentroid(hull, options.PadMm); if (padded.Count == 1) padded = SmallRectangle(padded[0], options.PadMm); else if (padded.Count == 2) padded = SegmentRectangle(padded[0], padded[1], options.PadMm); var minX = group.Points.Min(point => point.XMm) - options.PadMm; var minY = group.Points.Min(point => point.YMm) - options.PadMm; var maxX = group.Points.Max(point => point.XMm) + options.PadMm; var maxY = group.Points.Max(point => point.YMm) + options.PadMm; yield return new Contour( group.Key, group.DisplayName, group.ClusterIndex, group.Points.Count, group.ColorIndex, minX, minY, maxX, maxY, padded); } } static List ConvexHull(List points) { if (points.Count <= 1) return points; var lower = new List(); foreach (var point in points) { while (lower.Count >= 2 && Cross(lower[^2], lower[^1], point) <= 0) lower.RemoveAt(lower.Count - 1); lower.Add(point); } var upper = new List(); for (var i = points.Count - 1; i >= 0; i--) { var point = points[i]; while (upper.Count >= 2 && Cross(upper[^2], upper[^1], point) <= 0) upper.RemoveAt(upper.Count - 1); upper.Add(point); } lower.RemoveAt(lower.Count - 1); upper.RemoveAt(upper.Count - 1); lower.AddRange(upper); return lower; } static double Cross(HullPoint origin, HullPoint a, HullPoint b) { return (a.X - origin.X) * (b.Y - origin.Y) - (a.Y - origin.Y) * (b.X - origin.X); } static List PadPolygonFromCentroid(List points, double padMm) { if (points.Count == 0) return points; var cx = points.Average(point => point.X); var cy = points.Average(point => point.Y); return points.Select(point => { var dx = point.X - cx; var dy = point.Y - cy; var length = Math.Sqrt(dx * dx + dy * dy); if (length < 1e-9) return point; return new HullPoint(point.X + dx / length * padMm, point.Y + dy / length * padMm); }).ToList(); } static List SmallRectangle(HullPoint point, double padMm) { var pad = Math.Max(1.0, padMm); return [ new(point.X - pad, point.Y - pad), new(point.X + pad, point.Y - pad), new(point.X + pad, point.Y + pad), new(point.X - pad, point.Y + pad) ]; } static List SegmentRectangle(HullPoint a, HullPoint b, double padMm) { var pad = Math.Max(1.0, padMm); var dx = b.X - a.X; var dy = b.Y - a.Y; var length = Math.Sqrt(dx * dx + dy * dy); if (length < 1e-9) return SmallRectangle(a, pad); var nx = -dy / length * pad; var ny = dx / length * pad; return [ new(a.X + nx, a.Y + ny), new(b.X + nx, b.Y + ny), new(b.X - nx, b.Y - ny), new(a.X - nx, a.Y - ny) ]; } static void DrawContours(string dwgPath, List contours, Options options) { var app = StartOrConnectAutoCad(options.ProgId); SetProperty(app, "Visible", true); object? doc = null; try { var documents = GetProperty(app, "Documents") ?? throw new InvalidOperationException("AutoCAD documents collection unavailable."); doc = Invoke(documents, "Open", Path.GetFullPath(dwgPath)) ?? throw new InvalidOperationException($"AutoCAD failed to open DWG: {dwgPath}"); var modelSpace = GetProperty(doc, "ModelSpace") ?? throw new InvalidOperationException("AutoCAD ModelSpace unavailable."); var textStyleName = options.DrawLabels ? EnsureChineseTextStyle(doc, options) : ""; foreach (var contour in contours) { var layerName = $"AG4_OWN_{contour.ColorIndex:00}_{contour.ClusterIndex:00}"; EnsureLayer(doc, layerName, contour.ColorIndex); var coordinates = ToPolylineCoordinates(contour.Points); var polyline = Invoke(modelSpace, "AddLightWeightPolyline", coordinates); if (polyline == null) continue; SetProperty(polyline, "Closed", true); SetProperty(polyline, "Layer", layerName); SetProperty(polyline, "Color", contour.ColorIndex); SetProperty(polyline, "Lineweight", options.Lineweight); Safe(() => Invoke(polyline, "Update")); if (options.DrawLabels) DrawLabel(modelSpace, contour, layerName, textStyleName, options); } Invoke(doc, "SaveAs", Path.GetFullPath(dwgPath)); } finally { if (doc != null && options.CloseDocument) Safe(() => Invoke(doc, "Close", false)); } } static EntityColorResult ColorDwgEntities(string dwgPath, List records, Options options) { if (records.Count == 0) throw new InvalidOperationException("No ownership records are available for entity coloring."); var owners = BuildOwnerStyles(records, options).ToDictionary(owner => owner.Key, StringComparer.OrdinalIgnoreCase); var app = StartOrConnectAutoCad(options.ProgId); SetProperty(app, "Visible", true); object? doc = null; var colored = new List(); var candidateCount = 0; var skipped = new List(); var pending = new List(); var unassigned = new List(); var fillChanges = new List(); var continuityChanges = new List(); try { var documents = GetProperty(app, "Documents") ?? throw new InvalidOperationException("AutoCAD documents collection unavailable."); doc = Invoke(documents, "Open", Path.GetFullPath(dwgPath)) ?? throw new InvalidOperationException($"AutoCAD failed to open DWG: {dwgPath}"); foreach (var owner in owners.Values) EnsureLayer(doc, owner.LayerName, owner.ColorIndex); var modelSpace = GetProperty(doc, "ModelSpace") ?? throw new InvalidOperationException("AutoCAD ModelSpace unavailable."); var count = Convert.ToInt32(GetProperty(modelSpace, "Count"), CultureInfo.InvariantCulture); for (var i = 0; i < count; i++) { var entity = Safe(() => Invoke(modelSpace, "Item", i)); if (entity == null) continue; var objectName = ReadComString(entity, "ObjectName"); var layer = ReadComString(entity, "Layer"); var handle = ReadComString(entity, "Handle"); if (layer.StartsWith("AG4_", StringComparison.OrdinalIgnoreCase)) { skipped.Add(new EntitySkipRecord(handle, objectName, layer, "ag4_helper_layer", null, null, null)); continue; } if (!IsSupportedGeometry(objectName)) { skipped.Add(new EntitySkipRecord(handle, objectName, layer, "unsupported_entity_type", null, null, null)); continue; } var samples = GetEntitySamplePoints(entity, objectName); if (samples.Count == 0) { skipped.Add(new EntitySkipRecord(handle, objectName, layer, "no_sample_points", null, null, null)); continue; } if (!samples.Any(point => options.ContainsViewPoint(point.X, point.Y))) { var sample = samples[0]; skipped.Add(new EntitySkipRecord(handle, objectName, layer, "outside_view_bounds", sample.X, sample.Y, null)); continue; } candidateCount++; var assignment = FindMajorityOwnership(records, samples, options.MaxEntityAssignDistanceMm); if (assignment == null) { var nearest = FindBestSampleNearestOwnership(records, samples); unassigned.Add(new UnassignedEntityColor(entity, handle, objectName, layer, samples, nearest)); continue; } pending.Add(new PendingEntityColor(entity, handle, objectName, layer, samples, assignment)); } if (options.FillUnassignedByContinuity) fillChanges = FillUnassignedByContinuity(pending, unassigned, options); foreach (var item in unassigned.Where(item => !item.Filled)) { skipped.Add(new EntitySkipRecord( item.Handle, item.ObjectName, item.OriginalLayer, "no_near_ownership_point", item.Nearest?.Sample.X, item.Nearest?.Sample.Y, item.Nearest?.Nearest.DistanceMm)); } continuityChanges = options.SmoothContinuity ? SmoothContinuityAssignments(pending, options) : []; foreach (var item in pending) { var owner = owners[item.Assignment.Key]; SetProperty(item.Entity, "Layer", owner.LayerName); SetProperty(item.Entity, "Color", owner.ColorIndex); SetProperty(item.Entity, "Lineweight", options.EntityLineweight); Safe(() => Invoke(item.Entity, "Update")); colored.Add(new EntityColorRecord( item.Handle, item.ObjectName, owner.Key, owner.DisplayName, owner.ColorIndex, owner.LayerName, item.Assignment.SampleX, item.Assignment.SampleY, item.Assignment.AverageDistanceMm, item.OriginalKey, item.UnassignedFilled, item.ContinuityAdjusted)); } Invoke(doc, "SaveAs", Path.GetFullPath(dwgPath)); } finally { if (doc != null && options.CloseDocument) Safe(() => Invoke(doc, "Close", false)); } return new EntityColorResult(candidateCount, skipped.Count, colored, skipped, fillChanges, continuityChanges); } static List FillUnassignedByContinuity( List pending, List unassigned, Options options) { var changes = new List(); foreach (var item in unassigned) { if (item.Samples.Count < 1 || EstimatePathLength(item.Samples) > options.FillMaxEntityLengthMm) continue; var connected = pending .Where(other => other.Samples.Count >= 1 && AreEntitiesConnected(item.Samples, other.Samples, options.FillToleranceMm)) .ToList(); if (connected.Count == 0) continue; var best = connected .GroupBy(entity => entity.Assignment.Key, StringComparer.OrdinalIgnoreCase) .Select(group => new { Key = group.Key, Count = group.Count(), DisplayName = group.First().Assignment.DisplayName, AverageDistance = group.Average(entity => entity.Assignment.AverageDistanceMm) }) .OrderByDescending(group => group.Count) .ThenBy(group => group.AverageDistance) .First(); if (best.Count < options.FillMinSameNeighbors) continue; var sampleX = item.Samples.Average(point => point.X); var sampleY = item.Samples.Average(point => point.Y); var assignment = new EntityAssignment( best.Key, best.DisplayName, sampleX, sampleY, item.Nearest?.Nearest.DistanceMm ?? best.AverageDistance, best.Count, item.Samples.Count); var pendingItem = new PendingEntityColor(item.Entity, item.Handle, item.ObjectName, item.OriginalLayer, item.Samples, assignment) { UnassignedFilled = true }; pending.Add(pendingItem); item.Filled = true; changes.Add(new FillChangeRecord( item.Handle, item.ObjectName, best.Key, best.DisplayName, best.Count, connected.Count, EstimatePathLength(item.Samples), item.Nearest?.Nearest.DistanceMm)); } return changes; } static List SmoothContinuityAssignments(List pending, Options options) { var changes = new List(); foreach (var current in pending) { if (current.Samples.Count < 2 || EstimatePathLength(current.Samples) > options.ContinuityMaxEntityLengthMm) continue; var connected = pending .Where(other => !ReferenceEquals(other, current) && other.Samples.Count >= 2 && AreEntitiesConnected(current.Samples, other.Samples, options.ContinuityToleranceMm)) .ToList(); if (connected.Count == 0) continue; var best = connected .GroupBy(item => item.Assignment.Key, StringComparer.OrdinalIgnoreCase) .Select(group => new { Key = group.Key, Count = group.Count(), DisplayName = group.First().Assignment.DisplayName }) .OrderByDescending(group => group.Count) .First(); var currentNeighborCount = connected.Count(item => string.Equals(item.Assignment.Key, current.Assignment.Key, StringComparison.OrdinalIgnoreCase)); if (best.Count < options.ContinuityMinSameNeighbors || string.Equals(best.Key, current.Assignment.Key, StringComparison.OrdinalIgnoreCase) || currentNeighborCount > 0) { continue; } var before = current.Assignment; current.Assignment = before with { Key = best.Key, DisplayName = best.DisplayName }; current.ContinuityAdjusted = true; changes.Add(new ContinuityChangeRecord( current.Handle, current.ObjectName, before.Key, best.Key, before.DisplayName, best.DisplayName, best.Count, connected.Count, EstimatePathLength(current.Samples))); } return changes; } static bool AreEntitiesConnected(List a, List b, double toleranceMm) { var aTouches = TouchPoints(a); var bTouches = TouchPoints(b); var toleranceSquared = toleranceMm * toleranceMm; foreach (var pa in aTouches) { foreach (var pb in bTouches) { var dx = pa.X - pb.X; var dy = pa.Y - pb.Y; if (dx * dx + dy * dy <= toleranceSquared) return true; } } return false; } static List TouchPoints(List samples) { if (samples.Count == 0) return []; if (samples.Count == 1) return [samples[0]]; return [samples[0], samples[^1]]; } static double EstimatePathLength(List samples) { var length = 0.0; for (var i = 0; i < samples.Count - 1; i++) length += Distance(samples[i].X, samples[i].Y, samples[i + 1].X, samples[i + 1].Y); return length; } static IEnumerable BuildOwnerStyles(List records, Options options) { var index = 0; foreach (var group in records.GroupBy(record => record.Key, StringComparer.OrdinalIgnoreCase) .OrderByDescending(group => group.Count())) { var color = options.Colors[index % options.Colors.Length]; yield return new OwnerStyle( group.Key, group.First().DisplayName, color, $"AG4_ENTITY_{color:00}_{index + 1:00}"); index++; } } static NearestOwnership? FindNearestOwnership(List records, double xMm, double yMm) { PointRecord? best = null; var bestDistanceSquared = double.PositiveInfinity; foreach (var record in records) { var dx = record.XMm - xMm; var dy = record.YMm - yMm; var distanceSquared = dx * dx + dy * dy; if (distanceSquared >= bestDistanceSquared) continue; best = record; bestDistanceSquared = distanceSquared; } return best == null ? null : new NearestOwnership(best, Math.Sqrt(bestDistanceSquared)); } static EntityAssignment? FindMajorityOwnership(List records, List samples, double maxDistanceMm) { var votes = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var sample in samples) { var nearest = FindNearestOwnership(records, sample.X, sample.Y); if (nearest == null || nearest.DistanceMm > maxDistanceMm) continue; if (!votes.TryGetValue(nearest.Point.Key, out var accumulator)) { accumulator = new AssignmentAccumulator(nearest.Point.Key, nearest.Point.DisplayName); votes[nearest.Point.Key] = accumulator; } accumulator.Count++; accumulator.DistanceSum += nearest.DistanceMm; accumulator.SampleXSum += sample.X; accumulator.SampleYSum += sample.Y; } var best = votes.Values .OrderByDescending(vote => vote.Count) .ThenBy(vote => vote.DistanceSum / Math.Max(1, vote.Count)) .FirstOrDefault(); if (best == null) return null; return new EntityAssignment( best.Key, best.DisplayName, best.SampleXSum / best.Count, best.SampleYSum / best.Count, best.DistanceSum / best.Count, best.Count, samples.Count); } static SampleNearestOwnership? FindBestSampleNearestOwnership(List records, List samples) { SampleNearestOwnership? best = null; foreach (var sample in samples) { var nearest = FindNearestOwnership(records, sample.X, sample.Y); if (nearest == null) continue; if (best == null || nearest.DistanceMm < best.Nearest.DistanceMm) best = new SampleNearestOwnership(sample, nearest); } return best; } static bool IsSupportedGeometry(string objectName) { return objectName.Contains("Line", StringComparison.OrdinalIgnoreCase) || objectName.Contains("Polyline", StringComparison.OrdinalIgnoreCase) || objectName.Contains("Circle", StringComparison.OrdinalIgnoreCase) || objectName.Contains("Arc", StringComparison.OrdinalIgnoreCase) || objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase); } static List GetEntitySamplePoints(object entity, string objectName) { if (objectName.Contains("Polyline", StringComparison.OrdinalIgnoreCase)) return GetPolylineSamplePoints(entity); if (objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase)) return GetSplineSamplePoints(entity); if (objectName.Contains("Circle", StringComparison.OrdinalIgnoreCase)) return GetCircleSamplePoints(entity); if (objectName.Contains("Arc", StringComparison.OrdinalIgnoreCase)) return GetArcSamplePoints(entity); if (objectName.Contains("Line", StringComparison.OrdinalIgnoreCase) || objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase)) return GetLineLikeSamplePoints(entity); return TryGetBoundingBoxCenter(entity, out var x, out var y) ? [new HullPoint(x, y)] : []; } static List GetSplineSamplePoints(object entity) { var fitPoints = ToDoubleArray(Safe(() => GetProperty(entity, "FitPoints"))); if (fitPoints.Count >= 4) return GetCoordinatePathSamples(fitPoints); var controlPoints = ToDoubleArray(Safe(() => GetProperty(entity, "ControlPoints"))); if (controlPoints.Count >= 4) return GetCoordinatePathSamples(controlPoints); return GetLineLikeSamplePoints(entity); } static List GetLineLikeSamplePoints(object entity) { if (!TryGetPointProperty(entity, "StartPoint", out var sx, out var sy) || !TryGetPointProperty(entity, "EndPoint", out var ex, out var ey)) return TryGetBoundingBoxCenter(entity, out var bx, out var by) ? [new HullPoint(bx, by)] : []; var length = Distance(sx, sy, ex, ey); var count = Math.Clamp((int)Math.Ceiling(length / 8.0) + 1, 3, 11); return Enumerable.Range(0, count) .Select(i => { var t = count == 1 ? 0.5 : i / (double)(count - 1); return new HullPoint(sx + (ex - sx) * t, sy + (ey - sy) * t); }) .ToList(); } static List GetCircleSamplePoints(object entity) { if (!TryGetPointProperty(entity, "Center", out var cx, out var cy)) return TryGetBoundingBoxCenter(entity, out var bx, out var by) ? [new HullPoint(bx, by)] : []; var radius = ReadComDouble(entity, "Radius"); if (!double.IsFinite(radius) || radius <= 0) return [new HullPoint(cx, cy)]; return Enumerable.Range(0, 8) .Select(i => { var angle = Math.PI * 2.0 * i / 8.0; return new HullPoint(cx + Math.Cos(angle) * radius, cy + Math.Sin(angle) * radius); }) .ToList(); } static List GetArcSamplePoints(object entity) { if (!TryGetPointProperty(entity, "Center", out var cx, out var cy)) return TryGetBoundingBoxCenter(entity, out var bx, out var by) ? [new HullPoint(bx, by)] : []; var radius = ReadComDouble(entity, "Radius"); var start = ReadComDouble(entity, "StartAngle"); var end = ReadComDouble(entity, "EndAngle"); if (!double.IsFinite(radius) || !double.IsFinite(start) || !double.IsFinite(end) || radius <= 0) return TryGetBoundingBoxCenter(entity, out var bx, out var by) ? [new HullPoint(bx, by)] : []; var span = end - start; if (span < 0) span += Math.PI * 2.0; var count = Math.Clamp((int)Math.Ceiling(radius * span / 6.0) + 1, 3, 9); return Enumerable.Range(0, count) .Select(i => { var t = count == 1 ? 0.5 : i / (double)(count - 1); var angle = start + span * t; return new HullPoint(cx + Math.Cos(angle) * radius, cy + Math.Sin(angle) * radius); }) .ToList(); } static List GetPolylineSamplePoints(object entity) { var raw = Safe(() => GetProperty(entity, "Coordinates")); var values = ToDoubleArray(raw); if (values.Count < 4) return TryGetBoundingBoxCenter(entity, out var bx, out var by) ? [new HullPoint(bx, by)] : []; return GetCoordinatePathSamples(values); } static List GetCoordinatePathSamples(List values) { var dimensions = values.Count % 3 == 0 ? 3 : 2; var pointCount = values.Count / dimensions; var samples = new List(); for (var i = 0; i < pointCount - 1; i++) { var ax = values[i * dimensions]; var ay = values[i * dimensions + 1]; var bx = values[(i + 1) * dimensions]; var by = values[(i + 1) * dimensions + 1]; var length = Distance(ax, ay, bx, by); var count = Math.Clamp((int)Math.Ceiling(length / 8.0) + 1, 2, 8); for (var j = 0; j < count; j++) { var t = count == 1 ? 0.5 : j / (double)(count - 1); samples.Add(new HullPoint(ax + (bx - ax) * t, ay + (by - ay) * t)); } } return samples.Count > 0 ? samples : [new HullPoint(values[0], values[1])]; } static bool TryGetPointProperty(object entity, string propertyName, out double xMm, out double yMm) { xMm = double.NaN; yMm = double.NaN; var values = ToDoubleArray(Safe(() => GetProperty(entity, propertyName))); if (values.Count < 2) return false; xMm = values[0]; yMm = values[1]; return true; } static bool TryGetBoundingBoxCenter(object entity, out double xMm, out double yMm) { xMm = double.NaN; yMm = double.NaN; object? minObj = null; object? maxObj = null; try { var args = new object?[] { minObj, maxObj }; var modifiers = new ParameterModifier(2); modifiers[0] = true; modifiers[1] = true; entity.GetType().InvokeMember( "GetBoundingBox", BindingFlags.InvokeMethod, null, entity, args, [modifiers], CultureInfo.InvariantCulture, null); minObj = args[0]; maxObj = args[1]; } catch { return false; } var min = ToDoubleArray(minObj); var max = ToDoubleArray(maxObj); if (min.Count < 2 || max.Count < 2) return false; xMm = (min[0] + max[0]) / 2.0; yMm = (min[1] + max[1]) / 2.0; return true; } static List ToDoubleArray(object? value) { if (value == null) return []; if (value is Array array) { var values = new List(); foreach (var item in array) { if (item == null) continue; values.Add(Convert.ToDouble(item, CultureInfo.InvariantCulture)); } return values; } return []; } static string ReadComString(object target, string propertyName) { return Safe(() => GetProperty(target, propertyName)?.ToString()) ?? ""; } static double ReadComDouble(object target, string propertyName) { var value = Safe(() => GetProperty(target, propertyName)); if (value == null) return double.NaN; return Convert.ToDouble(value, CultureInfo.InvariantCulture); } static double Distance(double ax, double ay, double bx, double by) { var dx = ax - bx; var dy = ay - by; return Math.Sqrt(dx * dx + dy * dy); } static double[] ToPolylineCoordinates(List points) { var values = new double[points.Count * 2]; for (var i = 0; i < points.Count; i++) { values[i * 2] = points[i].X; values[i * 2 + 1] = points[i].Y; } return values; } static void EnsureLayer(object doc, string layerName, int colorIndex) { var layers = GetProperty(doc, "Layers") ?? throw new InvalidOperationException("AutoCAD Layers unavailable."); object? layer = null; try { layer = Invoke(layers, "Item", layerName); } catch { layer = Invoke(layers, "Add", layerName); } if (layer != null) SetProperty(layer, "Color", colorIndex); } static string EnsureChineseTextStyle(object doc, Options options) { var styles = GetProperty(doc, "TextStyles") ?? throw new InvalidOperationException("AutoCAD TextStyles unavailable."); object? style = null; try { style = Invoke(styles, "Item", options.TextStyleName); } catch { style = Invoke(styles, "Add", options.TextStyleName); } if (style == null) return options.TextStyleName; var fontPath = ResolveChineseFont(options); if (!string.IsNullOrWhiteSpace(fontPath)) SafeAction(() => SetProperty(style, "FontFile", fontPath)); // TrueType Chinese fonts are more reliable through SetFont than through // whatever Standard style the source DWG happens to use. Safe(() => Invoke(style, "SetFont", options.TextTypeface, false, false, 134, 49)); SafeAction(() => SetProperty(style, "Height", 0.0)); SafeAction(() => SetProperty(style, "Width", 1.0)); return options.TextStyleName; } static string ResolveChineseFont(Options options) { if (!string.IsNullOrWhiteSpace(options.TextFontFile) && File.Exists(options.TextFontFile)) return options.TextFontFile; var candidates = new[] { Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simsun.ttc"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf") }; return candidates.FirstOrDefault(File.Exists) ?? ""; } static void DrawLabel(object modelSpace, Contour contour, string layerName, string textStyleName, Options options) { var point = new[] { contour.MinX, contour.MaxY + options.LabelOffsetMm, 0.0 }; var text = Invoke(modelSpace, "AddText", ShortLabel(contour.DisplayName, 24), point, options.LabelHeightMm); if (text == null) return; SetProperty(text, "Layer", layerName); SetProperty(text, "Color", contour.ColorIndex); if (!string.IsNullOrWhiteSpace(textStyleName)) SafeAction(() => SetProperty(text, "StyleName", textStyleName)); Safe(() => Invoke(text, "Update")); } static void WriteReport(Options options, int inputPointCount, int groupCount, List contours) { Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(options.ReportPath))!); File.WriteAllText(options.ReportPath, JsonSerializer.Serialize(new { input_jsonl = Path.GetFullPath(options.InputJsonl), source_dwg = Path.GetFullPath(options.SourceDwg), output_dwg = Path.GetFullPath(options.OutputDwg), input_point_count = inputPointCount, group_count = groupCount, contour_count = contours.Count, step_mm = options.StepMm, pad_mm = options.PadMm, min_group_points = options.MinGroupPoints, min_cluster_points = options.MinClusterPoints, contours = contours.Select(contour => new { contour.Key, contour.DisplayName, contour.ClusterIndex, contour.PointCount, contour.ColorIndex, contour.MinX, contour.MinY, contour.MaxX, contour.MaxY, points = contour.Points.Select(point => new { x = point.X, y = point.Y }) }) }, new JsonSerializerOptions { WriteIndented = true })); } static void WriteEntityColorReport(Options options, int inputPointCount, EntityColorResult result) { var skipSummary = result.Skipped .GroupBy(item => item.Reason, StringComparer.OrdinalIgnoreCase) .OrderByDescending(group => group.Count()) .Select(group => new { reason = group.Key, count = group.Count() }) .ToList(); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(options.ReportPath))!); File.WriteAllText(options.ReportPath, JsonSerializer.Serialize(new { input_jsonl = Path.GetFullPath(options.InputJsonl), source_dwg = Path.GetFullPath(options.SourceDwg), output_dwg = Path.GetFullPath(options.OutputDwg), color_legend_text = Path.GetFullPath(options.LegendPath), mode = "color_entities", input_point_count = inputPointCount, entity_candidate_count = result.CandidateCount, colored_entity_count = result.Colored.Count, skipped_entity_count = result.SkippedCount, skipped_summary = skipSummary, skipped_samples = result.Skipped.Take(options.MaxSkippedSamples), fill_unassigned_by_continuity = new { enabled = options.FillUnassignedByContinuity, tolerance_mm = options.FillToleranceMm, min_same_neighbors = options.FillMinSameNeighbors, max_entity_length_mm = options.FillMaxEntityLengthMm, change_count = result.FillChanges.Count, changes = result.FillChanges }, continuity_smoothing = new { enabled = options.SmoothContinuity, tolerance_mm = options.ContinuityToleranceMm, min_same_neighbors = options.ContinuityMinSameNeighbors, max_entity_length_mm = options.ContinuityMaxEntityLengthMm, change_count = result.ContinuityChanges.Count, changes = result.ContinuityChanges }, max_entity_assign_distance_mm = options.MaxEntityAssignDistanceMm, view_bounds_mm = options.HasViewBounds ? new { min_x = options.ViewMinX, min_y = options.ViewMinY, max_x = options.ViewMaxX, max_y = options.ViewMaxY } : null, view_bounds_list_mm = options.ViewBoundsMm .Select(box => new { min_x = box.MinX, min_y = box.MinY, max_x = box.MaxX, max_y = box.MaxY }) .ToList(), colored = result.Colored }, new JsonSerializerOptions { WriteIndented = true })); } static void WriteEntityColorLegend(Options options, int inputPointCount, EntityColorResult result) { Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(options.LegendPath))!); var groups = result.Colored .GroupBy(item => item.Key, StringComparer.OrdinalIgnoreCase) .Select(group => { var first = group.First(); return new { first.Key, first.DisplayName, first.ColorIndex, first.LayerName, EntityCount = group.Count(), FilledCount = group.Count(item => item.UnassignedFilled), AdjustedCount = group.Count(item => item.ContinuityAdjusted), ObjectTypes = group .GroupBy(item => item.ObjectName, StringComparer.OrdinalIgnoreCase) .OrderByDescending(typeGroup => typeGroup.Count()) .Select(typeGroup => $"{typeGroup.Key}={typeGroup.Count()}") .ToList() }; }) .OrderBy(item => item.ColorIndex) .ThenByDescending(item => item.EntityCount) .ToList(); var sb = new StringBuilder(); sb.AppendLine("上色二维图线条归属说明"); sb.AppendLine($"原始DWG: {Path.GetFullPath(options.SourceDwg)}"); sb.AppendLine($"上色DWG: {Path.GetFullPath(options.OutputDwg)}"); sb.AppendLine($"归属采样JSONL: {Path.GetFullPath(options.InputJsonl)}"); sb.AppendLine($"归属采样点数量: {inputPointCount}"); sb.AppendLine($"已上色可绘制线条数量: {result.Colored.Count}"); sb.AppendLine(); sb.AppendLine("使用方式:"); sb.AppendLine("上色后的零件位置说明图中,同一种线条颜色对应下面列出的同一个零件/组件归属。分析时请把本说明、上色位置图、学生原始标注图一起使用。"); sb.AppendLine(); sb.AppendLine("颜色归属:"); if (groups.Count == 0) { sb.AppendLine("- 没有成功上色的可绘制线条。"); } else { foreach (var group in groups) { sb.AppendLine( $"- {AciColorDescription(group.ColorIndex)} | 图层={group.LayerName} | 上色线条数={group.EntityCount} | 归属={group.DisplayName}"); sb.AppendLine($" 归属键={group.Key}"); if (group.FilledCount > 0 || group.AdjustedCount > 0) sb.AppendLine($" 连续性补色={group.FilledCount}, 连续性修正={group.AdjustedCount}"); if (group.ObjectTypes.Count > 0) sb.AppendLine($" 图元类型={string.Join(", ", group.ObjectTypes)}"); } } sb.AppendLine(); sb.AppendLine("备注:"); sb.AppendLine("- ACI 是 AutoCAD Color Index,即 DWG 图元使用的颜色编号。"); sb.AppendLine("- Hex 是用于 AI 读图理解的近似屏幕颜色,不要求与 CAD 显示完全逐像素一致。"); File.WriteAllText(options.LegendPath, sb.ToString(), new UTF8Encoding(false)); } static string AciColorDescription(int colorIndex) { var color = KnownAciColor(colorIndex); if (color == null) return $"ACI {colorIndex}"; return $"ACI {colorIndex} {color.Value.Name} 近似 {color.Value.Hex}"; } static (string Name, string Hex)? KnownAciColor(int colorIndex) => colorIndex switch { 1 => ("红色", "#FF0000"), 2 => ("黄色", "#FFFF00"), 3 => ("绿色", "#00FF00"), 4 => ("青色", "#00FFFF"), 5 => ("蓝色", "#0000FF"), 6 => ("品红色", "#FF00FF"), 7 => ("白色/黑色", "#FFFFFF"), 8 => ("灰色", "#808080"), 9 => ("浅灰色", "#C0C0C0"), 10 => ("红橙色", "#FF7F00"), 30 => ("橙色", "#FFBF00"), 94 => ("黄绿色", "#99CC00"), 140 => ("蓝绿色", "#00CC99"), 170 => ("蓝紫色", "#3366CC"), 200 => ("紫色", "#9933CC"), _ => null }; static object StartOrConnectAutoCad(string progId) { object? app = null; try { app = GetActiveObjectFromProgId(progId); } catch { app = null; } if (app != null) return app; StartAutoCadFromRegistry(progId); var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60); while (DateTime.UtcNow < deadline) { try { return GetActiveObjectFromProgId(progId); } catch { Thread.Sleep(500); } } throw new InvalidOperationException($"AutoCAD startup or COM connection failed: {progId}"); } static object GetActiveObjectFromProgId(string progId) { var hr = CLSIDFromProgID(progId, out var clsid); if (hr < 0) Marshal.ThrowExceptionForHR(hr); return GetActiveObject(ref clsid, IntPtr.Zero); } 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 not found. ProgID={progId}, LocalServer32={command}"); Process.Start(new ProcessStartInfo { FileName = exe, Arguments = "/Automation", UseShellExecute = true, WorkingDirectory = Path.GetDirectoryName(exe) ?? Environment.CurrentDirectory, WindowStyle = ProcessWindowStyle.Hidden }); } 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; } [DllImport("ole32.dll", CharSet = CharSet.Unicode)] static extern int CLSIDFromProgID(string progId, out Guid clsid); [DllImport("oleaut32.dll", PreserveSig = false)] [return: MarshalAs(UnmanagedType.IUnknown)] static extern object GetActiveObject(ref Guid clsid, IntPtr reserved); static object? Invoke(object target, string name, params object?[] args) { return RetryCom(() => target.GetType().InvokeMember( name, BindingFlags.InvokeMethod, null, target, args)); } static object? GetProperty(object target, string name) { return RetryCom(() => target.GetType().InvokeMember( name, BindingFlags.GetProperty, null, target, [])); } static void SetProperty(object target, string name, object? value) { RetryCom(() => target.GetType().InvokeMember( name, BindingFlags.SetProperty, null, target, [value])); } static object? RetryCom(Func action) { Exception? last = null; for (var attempt = 0; attempt < 60; attempt++) { try { return action(); } catch (TargetInvocationException ex) when (IsRetryLater(ex.InnerException)) { last = ex; Thread.Sleep(250); } catch (COMException ex) when (IsRetryLater(ex)) { last = ex; Thread.Sleep(250); } } throw last ?? new InvalidOperationException("COM call failed."); } static bool IsRetryLater(Exception? ex) { return ex is COMException com && unchecked((uint)com.HResult) == 0x8001010A; } static T? Safe(Func action) { try { return action(); } catch { return default; } } static void SafeAction(Action action) { try { action(); } catch { } } static double ReadDouble(JsonElement element, params string[] names) { foreach (var name in names) { if (!element.TryGetProperty(name, out var value)) continue; if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number)) return number; if (value.ValueKind == JsonValueKind.String && double.TryParse(value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out number)) return number; } return double.NaN; } static bool ReadBool(JsonElement element, params string[] names) { foreach (var name in names) { if (!element.TryGetProperty(name, out var value)) continue; if (value.ValueKind == JsonValueKind.True) return true; if (value.ValueKind == JsonValueKind.False) return false; if (value.ValueKind == JsonValueKind.String && bool.TryParse(value.GetString(), out var parsed)) return parsed; } return false; } static int ReadInt(JsonElement element, params string[] names) { foreach (var name in names) { if (!element.TryGetProperty(name, out var value)) continue; if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number; if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out number)) return number; } return -1; } static string ReadString(JsonElement element, params string[] names) { foreach (var name in names) { if (!element.TryGetProperty(name, out var value)) continue; return value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : value.ToString(); } return ""; } static string DisplayNameFromKey(string key) { const string prefix = "name="; var start = key.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); if (start < 0) return key; start += prefix.Length; var end = key.IndexOf(';', start); return end > start ? key[start..end] : key[start..]; } static string NormalizeOwnershipKey(string key) { var name = ReadKeyField(key, "name"); var path = ReadKeyField(key, "path"); var config = ReadKeyField(key, "config"); if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(path)) return key; var leafName = name .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .LastOrDefault(); if (string.IsNullOrWhiteSpace(leafName)) leafName = name; return $"name={leafName}; path={path}; config={config}"; } static string ReadKeyField(string key, string fieldName) { var prefix = fieldName + "="; var start = key.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); if (start < 0) return ""; start += prefix.Length; var end = key.IndexOf(';', start); return end > start ? key[start..end].Trim() : key[start..].Trim(); } static string ShortLabel(string text, int maxLength) { return text.Length <= maxLength ? text : text[..maxLength]; } static double Round(double value) { return Math.Round(value, 6); } sealed record PointRecord(string Key, string DisplayName, double XMm, double YMm, int Row, int Column); sealed record ComponentGroup( string Key, string DisplayName, int ClusterIndex, List Points, int ColorIndex); sealed record HullPoint(double X, double Y); sealed record Contour( string Key, string DisplayName, int ClusterIndex, int PointCount, int ColorIndex, double MinX, double MinY, double MaxX, double MaxY, List Points); sealed record OwnerStyle(string Key, string DisplayName, int ColorIndex, string LayerName); sealed record ViewBoundsMm(double MinX, double MinY, double MaxX, double MaxY); sealed record NearestOwnership(PointRecord Point, double DistanceMm); sealed record SampleNearestOwnership(HullPoint Sample, NearestOwnership Nearest); sealed record EntityAssignment( string Key, string DisplayName, double SampleX, double SampleY, double AverageDistanceMm, int Votes, int SampleCount); sealed class AssignmentAccumulator(string key, string displayName) { public string Key { get; } = key; public string DisplayName { get; } = displayName; public int Count { get; set; } public double DistanceSum { get; set; } public double SampleXSum { get; set; } public double SampleYSum { get; set; } } sealed record EntityColorResult( int CandidateCount, int SkippedCount, List Colored, List Skipped, List FillChanges, List ContinuityChanges); sealed record EntityColorRecord( string Handle, string ObjectName, string Key, string DisplayName, int ColorIndex, string LayerName, double SampleX, double SampleY, double DistanceMm, string OriginalKey, bool UnassignedFilled, bool ContinuityAdjusted); sealed record EntitySkipRecord( string Handle, string ObjectName, string LayerName, string Reason, double? SampleX, double? SampleY, double? NearestDistanceMm); sealed record FillChangeRecord( string Handle, string ObjectName, string ToKey, string ToDisplayName, int SameNeighborCount, int ConnectedNeighborCount, double EntityLengthMm, double? NearestDistanceMm); sealed record ContinuityChangeRecord( string Handle, string ObjectName, string FromKey, string ToKey, string FromDisplayName, string ToDisplayName, int SameNeighborCount, int ConnectedNeighborCount, double EntityLengthMm); sealed class PendingEntityColor( object entity, string handle, string objectName, string originalLayer, List samples, EntityAssignment assignment) { public object Entity { get; } = entity; public string Handle { get; } = handle; public string ObjectName { get; } = objectName; public string OriginalLayer { get; } = originalLayer; public List Samples { get; } = samples; public string OriginalKey { get; } = assignment.Key; public EntityAssignment Assignment { get; set; } = assignment; public bool UnassignedFilled { get; set; } public bool ContinuityAdjusted { get; set; } } sealed class UnassignedEntityColor( object entity, string handle, string objectName, string originalLayer, List samples, SampleNearestOwnership? nearest) { public object Entity { get; } = entity; public string Handle { get; } = handle; public string ObjectName { get; } = objectName; public string OriginalLayer { get; } = originalLayer; public List Samples { get; } = samples; public SampleNearestOwnership? Nearest { get; } = nearest; public bool Filled { get; set; } } sealed class Options { public string InputJsonl { get; private set; } = ""; public string SourceDwg { get; private set; } = ""; public string OutputDwg { get; private set; } = ""; public string ReportPath { get; private set; } = ""; public string LegendPath { get; private set; } = ""; public string ProgId { get; private set; } = "AutoCAD.Application.23.1"; public double StepMm { get; private set; } = 4.0; public double PadMm { get; private set; } = 2.0; public int MinGroupPoints { get; private set; } = 2; public int MinClusterPoints { get; private set; } = 2; public int Lineweight { get; private set; } = 35; public int EntityLineweight { get; private set; } = 25; public bool DrawLabels { get; private set; } = true; public bool ColorEntities { get; private set; } public bool CloseDocument { get; private set; } public double LabelHeightMm { get; private set; } = 3.0; public double LabelOffsetMm { get; private set; } = 2.5; public string TextStyleName { get; private set; } = "AG4_CN_TEXT"; public string TextTypeface { get; private set; } = "SimSun"; public string TextFontFile { get; private set; } = ""; public double MaxEntityAssignDistanceMm { get; private set; } = 8.0; public double ViewMinX { get; private set; } = double.NegativeInfinity; public double ViewMinY { get; private set; } = double.NegativeInfinity; public double ViewMaxX { get; private set; } = double.PositiveInfinity; public double ViewMaxY { get; private set; } = double.PositiveInfinity; public bool HasViewBounds { get; private set; } public List ViewBoundsMm { get; } = new(); public int MaxSkippedSamples { get; private set; } = 200; public bool SmoothContinuity { get; private set; } = true; public double ContinuityToleranceMm { get; private set; } = 1.0; public int ContinuityMinSameNeighbors { get; private set; } = 2; public double ContinuityMaxEntityLengthMm { get; private set; } = 40.0; public bool FillUnassignedByContinuity { get; private set; } = true; public double FillToleranceMm { get; private set; } = 2.0; public int FillMinSameNeighbors { get; private set; } = 1; public double FillMaxEntityLengthMm { get; private set; } = 60.0; public int[] Colors { get; private set; } = [1, 3, 5, 6, 2, 4, 30, 94, 140, 200, 10, 170]; public bool ContainsViewPoint(double xMm, double yMm) { if (ViewBoundsMm.Count == 0) return xMm >= ViewMinX && xMm <= ViewMaxX && yMm >= ViewMinY && yMm <= ViewMaxY; return ViewBoundsMm.Any(box => xMm >= box.MinX && xMm <= box.MaxX && yMm >= box.MinY && yMm <= box.MaxY); } public static Options Parse(string[] args) { var options = new Options(); for (var i = 0; i < args.Length; i++) { var arg = args[i]; string Next() => i + 1 < args.Length ? args[++i] : throw new ArgumentException($"Missing value after {arg}"); switch (arg.ToLowerInvariant()) { case "--input": case "--input-jsonl": options.InputJsonl = Next(); break; case "--source-dwg": options.SourceDwg = Next(); break; case "--output-dwg": options.OutputDwg = Next(); break; case "--report": case "--output": options.ReportPath = Next(); break; case "--legend": case "--color-legend": case "--legend-text": options.LegendPath = Next(); break; case "--prog-id": options.ProgId = Next(); break; case "--step-mm": options.StepMm = double.Parse(Next(), CultureInfo.InvariantCulture); break; case "--pad-mm": options.PadMm = double.Parse(Next(), CultureInfo.InvariantCulture); break; case "--min-group-points": options.MinGroupPoints = int.Parse(Next(), CultureInfo.InvariantCulture); break; case "--min-cluster-points": options.MinClusterPoints = int.Parse(Next(), CultureInfo.InvariantCulture); break; case "--lineweight": options.Lineweight = int.Parse(Next(), CultureInfo.InvariantCulture); break; case "--entity-lineweight": options.EntityLineweight = int.Parse(Next(), CultureInfo.InvariantCulture); break; case "--colors": options.Colors = Next() .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(item => int.Parse(item, CultureInfo.InvariantCulture)) .ToArray(); if (options.Colors.Length == 0) throw new ArgumentException("--colors requires at least one AutoCAD color index."); break; case "--color-entities": options.ColorEntities = true; break; case "--max-entity-assign-distance-mm": options.MaxEntityAssignDistanceMm = double.Parse(Next(), CultureInfo.InvariantCulture); break; case "--view-bounds-mm": var parts = Next().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length != 4) throw new ArgumentException("--view-bounds-mm requires minX,minY,maxX,maxY."); options.ViewMinX = double.Parse(parts[0], CultureInfo.InvariantCulture); options.ViewMinY = double.Parse(parts[1], CultureInfo.InvariantCulture); options.ViewMaxX = double.Parse(parts[2], CultureInfo.InvariantCulture); options.ViewMaxY = double.Parse(parts[3], CultureInfo.InvariantCulture); options.HasViewBounds = true; options.ViewBoundsMm.Add(new ViewBoundsMm(options.ViewMinX, options.ViewMinY, options.ViewMaxX, options.ViewMaxY)); break; case "--max-skipped-samples": options.MaxSkippedSamples = Math.Max(0, int.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--smooth-continuity": options.SmoothContinuity = true; break; case "--no-smooth-continuity": options.SmoothContinuity = false; break; case "--continuity-tolerance-mm": options.ContinuityToleranceMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--continuity-min-same-neighbors": options.ContinuityMinSameNeighbors = Math.Max(1, int.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--continuity-max-entity-length-mm": options.ContinuityMaxEntityLengthMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--fill-unassigned-by-continuity": options.FillUnassignedByContinuity = true; break; case "--no-fill-unassigned-by-continuity": options.FillUnassignedByContinuity = false; break; case "--fill-tolerance-mm": options.FillToleranceMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--fill-min-same-neighbors": options.FillMinSameNeighbors = Math.Max(1, int.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--fill-max-entity-length-mm": options.FillMaxEntityLengthMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture)); break; case "--no-labels": options.DrawLabels = false; break; case "--close-document": options.CloseDocument = true; break; case "--label-height-mm": options.LabelHeightMm = double.Parse(Next(), CultureInfo.InvariantCulture); break; case "--label-offset-mm": options.LabelOffsetMm = double.Parse(Next(), CultureInfo.InvariantCulture); break; case "--text-style": options.TextStyleName = Next(); break; case "--text-typeface": options.TextTypeface = Next(); break; case "--text-font-file": options.TextFontFile = Next(); break; } } if (string.IsNullOrWhiteSpace(options.ReportPath) && !string.IsNullOrWhiteSpace(options.OutputDwg)) options.ReportPath = Path.ChangeExtension(options.OutputDwg, ".ownership-contours.json"); if (string.IsNullOrWhiteSpace(options.LegendPath) && !string.IsNullOrWhiteSpace(options.ReportPath)) options.LegendPath = Path.ChangeExtension(options.ReportPath, ".color-legend.txt"); return options; } }