Files
2026-07-17 17:45:56 +08:00

1808 lines
63 KiB
C#

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<PointRecord> 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<ComponentGroup> BuildGroups(List<PointRecord> 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<List<PointRecord>> SplitConnectedClusters(List<PointRecord> points, double maxDistanceMm)
{
var clusters = new List<List<PointRecord>>();
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<PointRecord>();
var queue = new Queue<int>();
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<Contour> BuildContours(List<ComponentGroup> 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<HullPoint> ConvexHull(List<HullPoint> points)
{
if (points.Count <= 1)
return points;
var lower = new List<HullPoint>();
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<HullPoint>();
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<HullPoint> PadPolygonFromCentroid(List<HullPoint> 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<HullPoint> 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<HullPoint> 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<Contour> 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<PointRecord> 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<EntityColorRecord>();
var candidateCount = 0;
var skipped = new List<EntitySkipRecord>();
var pending = new List<PendingEntityColor>();
var unassigned = new List<UnassignedEntityColor>();
var fillChanges = new List<FillChangeRecord>();
var continuityChanges = new List<ContinuityChangeRecord>();
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<FillChangeRecord> FillUnassignedByContinuity(
List<PendingEntityColor> pending,
List<UnassignedEntityColor> unassigned,
Options options)
{
var changes = new List<FillChangeRecord>();
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<ContinuityChangeRecord> SmoothContinuityAssignments(List<PendingEntityColor> pending, Options options)
{
var changes = new List<ContinuityChangeRecord>();
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<HullPoint> a, List<HullPoint> 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<HullPoint> TouchPoints(List<HullPoint> samples)
{
if (samples.Count == 0)
return [];
if (samples.Count == 1)
return [samples[0]];
return [samples[0], samples[^1]];
}
static double EstimatePathLength(List<HullPoint> 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<OwnerStyle> BuildOwnerStyles(List<PointRecord> 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<PointRecord> 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<PointRecord> records, List<HullPoint> samples, double maxDistanceMm)
{
var votes = new Dictionary<string, AssignmentAccumulator>(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<PointRecord> records, List<HullPoint> 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<HullPoint> 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<HullPoint> 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<HullPoint> 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<HullPoint> 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<HullPoint> 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<HullPoint> 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<HullPoint> GetCoordinatePathSamples(List<double> values)
{
var dimensions = values.Count % 3 == 0 ? 3 : 2;
var pointCount = values.Count / dimensions;
var samples = new List<HullPoint>();
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<double> ToDoubleArray(object? value)
{
if (value == null)
return [];
if (value is Array array)
{
var values = new List<double>();
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<HullPoint> 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<Contour> 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<object?> 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<T>(Func<T> 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<PointRecord> 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<HullPoint> 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<EntityColorRecord> Colored,
List<EntitySkipRecord> Skipped,
List<FillChangeRecord> FillChanges,
List<ContinuityChangeRecord> 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<HullPoint> 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<HullPoint> 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<HullPoint> 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<HullPoint> 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> 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;
}
}