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

1146 lines
39 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Win32;
var options = Options.Parse(args);
if (!File.Exists(options.DrawingPath))
throw new FileNotFoundException($"DWG not found: {options.DrawingPath}");
if (options.Exports.Count == 0)
throw new ArgumentException("At least one --view-bounds-mm value is required.");
Directory.CreateDirectory(options.OutputDir);
var sourceDrawingPath = Path.GetFullPath(options.DrawingPath);
var exportDrawingPath = PrepareExportDrawing(sourceDrawingPath, options);
var app = StartOrConnectAutoCad(options.ProgId);
SetProperty(app, "Visible", options.Visible);
object? doc = null;
var results = new List<object>();
var cleanup = AnnotationCleanupResult.Empty;
try
{
doc = OpenDocument(app, exportDrawingPath, readOnly: !options.RemoveAnnotations && !options.HasHighlights);
WaitForQuiescent(app, TimeSpan.FromSeconds(30));
TryInvoke(doc, "SetVariable", "BACKGROUNDPLOT", 0);
TryInvoke(doc, "SetVariable", "FILEDIA", 0);
TryInvoke(doc, "SetVariable", "CMDDIA", 0);
if (options.RemoveAnnotations)
{
cleanup = RemoveAnnotations(doc, options);
Safe(() => Invoke(doc, "SaveAs", exportDrawingPath));
WaitForQuiescent(app, TimeSpan.FromSeconds(20));
Console.WriteLine($"annotation_cleanup candidates={cleanup.CandidateCount} deleted={cleanup.DeletedCount} dwg={exportDrawingPath}");
}
var highlight = ApplyHighlights(doc, options);
if (highlight.HighlightedCount > 0 || highlight.BoxCount > 0)
{
Safe(() => Invoke(doc, "SaveAs", exportDrawingPath));
WaitForQuiescent(app, TimeSpan.FromSeconds(20));
Console.WriteLine($"highlight handles={highlight.RequestedCount} highlighted={highlight.HighlightedCount} boxes={highlight.BoxCount} failed={highlight.FailedHandles.Count}");
}
foreach (var export in options.Exports)
{
var outputPath = Path.GetFullPath(Path.Combine(options.OutputDir, export.OutputName));
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
var padded = export.Bounds.Expand(options.PaddingMm);
var plotBounds = padded;
PlotResult result;
if (options.IncludeAnnotationsInBounds && !options.RemoveAnnotations)
plotBounds = ExpandBoundsToConnectedAnnotations(doc, export.Bounds, padded, options);
result = PlotWindowToPng(doc, outputPath, plotBounds, options);
results.Add(new
{
name = export.Name,
output_png = outputPath,
bounds_mm = export.Bounds,
padded_bounds_mm = padded,
plot_bounds_mm = plotBounds,
annotation_bounds_expanded = !BoundsNearlyEqual(padded, plotBounds),
result.Device,
result.Media,
result.Ok
});
Console.WriteLine($"exported name={export.Name} ok={result.Ok} png={outputPath}");
}
}
finally
{
if (doc != null && options.CloseDocument)
Safe(() => Invoke(doc, "Close", false));
ReleaseCom(doc);
ReleaseCom(app);
}
var reportPath = string.IsNullOrWhiteSpace(options.ReportPath)
? Path.Combine(options.OutputDir, "dwg-view-png-export-report.json")
: options.ReportPath;
File.WriteAllText(reportPath, JsonSerializer.Serialize(new
{
source_drawing_path = sourceDrawingPath,
drawing_path = exportDrawingPath,
working_dwg = options.WorkingDwgPath,
remove_annotations = options.RemoveAnnotations,
highlight_handles = options.HighlightHandles,
highlight_boxes_mm = options.HighlightBoxes,
highlight_color_index = options.HighlightColorIndex,
highlight_lineweight = options.HighlightLineweight,
include_annotations_in_bounds = options.IncludeAnnotationsInBounds,
annotation_connect_tolerance_mm = options.AnnotationConnectToleranceMm,
max_annotation_expansion_mm = options.MaxAnnotationExpansionMm,
max_annotation_box_size_mm = options.MaxAnnotationBoxSizeMm,
annotation_cleanup = cleanup,
output_dir = Path.GetFullPath(options.OutputDir),
padding_mm = options.PaddingMm,
device = options.DeviceName,
requested_media = options.MediaName,
exports = results
}, new JsonSerializerOptions { WriteIndented = true }));
Console.WriteLine($"report={Path.GetFullPath(reportPath)}");
static string PrepareExportDrawing(string sourceDrawingPath, Options options)
{
if (!options.RemoveAnnotations && !options.HasHighlights)
{
options.WorkingDwgPath = "";
return sourceDrawingPath;
}
var workingDwg = options.WorkingDwgPath;
if (string.IsNullOrWhiteSpace(workingDwg))
{
var baseName = Path.GetFileNameWithoutExtension(sourceDrawingPath);
workingDwg = Path.Combine(options.OutputDir, "_working", $"{baseName}.annotation-clean.dwg");
options.WorkingDwgPath = workingDwg;
}
if (string.IsNullOrWhiteSpace(workingDwg))
return sourceDrawingPath;
var workingPath = Path.GetFullPath(workingDwg);
if (string.Equals(sourceDrawingPath, workingPath, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("--working-dwg must not point at the source DWG.");
Directory.CreateDirectory(Path.GetDirectoryName(workingPath)!);
File.Copy(sourceDrawingPath, workingPath, overwrite: true);
return workingPath;
}
static HighlightResult ApplyHighlights(object doc, Options options)
{
if (!options.HasHighlights)
return HighlightResult.Empty;
var failed = new List<string>();
var highlighted = 0;
foreach (var handle in options.HighlightHandles)
{
if (string.IsNullOrWhiteSpace(handle))
continue;
object? entity = null;
try
{
entity = Invoke(doc, "HandleToObject", handle);
if (entity == null)
{
failed.Add(handle);
continue;
}
TrySetProperty(entity, "Color", options.HighlightColorIndex);
TrySetProperty(entity, "Lineweight", options.HighlightLineweight);
Safe(() => Invoke(entity, "Highlight", true));
Safe(() => Invoke(entity, "Update"));
highlighted++;
}
catch
{
failed.Add(handle);
}
finally
{
ReleaseCom(entity);
}
}
var boxes = ApplyHighlightBoxes(doc, options);
return new HighlightResult(options.HighlightHandles.Count, highlighted, boxes, failed);
}
static int ApplyHighlightBoxes(object doc, Options options)
{
if (options.HighlightBoxes.Count == 0)
return 0;
object? modelSpace = null;
var added = 0;
try
{
modelSpace = GetProperty(doc, "ModelSpace") ?? throw new InvalidOperationException("ModelSpace unavailable.");
foreach (var box in options.HighlightBoxes)
{
var expanded = box.Bounds.Expand(options.HighlightBoxPaddingMm);
var points = new[]
{
new[] { expanded.MinX, expanded.MinY, 0.0 },
new[] { expanded.MaxX, expanded.MinY, 0.0 },
new[] { expanded.MaxX, expanded.MaxY, 0.0 },
new[] { expanded.MinX, expanded.MaxY, 0.0 }
};
for (var i = 0; i < points.Length; i++)
{
object? line = null;
try
{
line = Invoke(modelSpace, "AddLine", points[i], points[(i + 1) % points.Length]);
if (line == null)
continue;
TrySetProperty(line, "Color", options.HighlightColorIndex);
TrySetProperty(line, "Lineweight", options.HighlightLineweight);
Safe(() => Invoke(line, "Update"));
added++;
}
finally
{
ReleaseCom(line);
}
}
}
}
finally
{
ReleaseCom(modelSpace);
}
return added / 4;
}
static AnnotationCleanupResult RemoveAnnotations(object doc, Options options)
{
var deletedByObjectName = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var samples = new List<AnnotationDeleteSample>();
var candidateCount = 0;
var deletedCount = 0;
foreach (var spaceName in new[] { "ModelSpace", "PaperSpace" })
{
var space = GetProperty(doc, spaceName);
if (space == null)
continue;
try
{
var count = Convert.ToInt32(GetProperty(space, "Count"), CultureInfo.InvariantCulture);
for (var i = count - 1; i >= 0; i--)
{
var entity = TryInvoke(space, "Item", i);
if (entity == null)
continue;
try
{
var objectName = ReadComString(entity, "ObjectName");
var layer = ReadComString(entity, "Layer");
var handle = ReadComString(entity, "Handle");
var name = FirstNonEmpty(ReadComString(entity, "EffectiveName"), ReadComString(entity, "Name"));
if (!IsAnnotationEntity(entity, objectName, layer, name, out var reason))
continue;
candidateCount++;
if (!TryDeleteEntity(entity))
continue;
deletedCount++;
deletedByObjectName[objectName] = deletedByObjectName.GetValueOrDefault(objectName) + 1;
if (samples.Count < options.MaxCleanupSamples)
samples.Add(new AnnotationDeleteSample(spaceName, handle, objectName, layer, name, reason));
}
finally
{
ReleaseCom(entity);
}
}
}
finally
{
ReleaseCom(space);
}
}
return new AnnotationCleanupResult(candidateCount, deletedCount, deletedByObjectName, samples);
}
static bool TryDeleteEntity(object entity)
{
try
{
Invoke(entity, "Delete");
return true;
}
catch
{
return false;
}
}
static BoundsMm ExpandBoundsToConnectedAnnotations(
object doc,
BoundsMm viewBounds,
BoundsMm basePlotBounds,
Options options)
{
var limitBounds = viewBounds.Expand(options.MaxAnnotationExpansionMm);
var seedBounds = viewBounds.Expand(options.ViewSeedToleranceMm);
var seedBoxes = new List<BoundsMm>();
var annotationBoxes = new List<BoundsMm>();
foreach (var spaceName in new[] { "ModelSpace", "PaperSpace" })
{
var space = GetProperty(doc, spaceName);
if (space == null)
continue;
try
{
var count = Convert.ToInt32(GetProperty(space, "Count"), CultureInfo.InvariantCulture);
for (var i = 0; i < count; i++)
{
var entity = TryInvoke(space, "Item", i);
if (entity == null)
continue;
var objectName = ReadComString(entity, "ObjectName");
var layer = ReadComString(entity, "Layer");
var name = FirstNonEmpty(ReadComString(entity, "EffectiveName"), ReadComString(entity, "Name"));
try
{
var hasBounds = TryGetEntityBounds(entity, out var bounds);
var isAnnotation = IsAnnotationEntityForBounds(objectName, layer, name);
if (!hasBounds || !bounds.IsFinite || !bounds.Intersects(limitBounds))
continue;
if (isAnnotation)
{
if (bounds.Width <= options.MaxAnnotationBoxSizeMm &&
bounds.Height <= options.MaxAnnotationBoxSizeMm)
{
annotationBoxes.Add(bounds);
}
}
else if (bounds.Intersects(seedBounds))
{
seedBoxes.Add(bounds);
}
}
finally
{
ReleaseCom(entity);
}
}
}
finally
{
ReleaseCom(space);
}
}
var connectedBoxes = seedBoxes.ToList();
if (connectedBoxes.Count == 0)
connectedBoxes.Add(viewBounds);
var remainingAnnotations = annotationBoxes.ToList();
var plotBounds = basePlotBounds;
var included = 0;
var changed = true;
while (changed)
{
changed = false;
for (var i = remainingAnnotations.Count - 1; i >= 0; i--)
{
var candidate = remainingAnnotations[i];
if (!connectedBoxes.Any(box => box.DistanceTo(candidate) <= options.AnnotationConnectToleranceMm))
continue;
connectedBoxes.Add(candidate);
plotBounds = plotBounds.Union(candidate.ClampTo(limitBounds));
remainingAnnotations.RemoveAt(i);
included++;
changed = true;
}
}
Console.WriteLine(
$"annotation_bounds connected_annotations={included} seed_entities={seedBoxes.Count} candidates={annotationBoxes.Count} base=({basePlotBounds.MinX:F3},{basePlotBounds.MinY:F3},{basePlotBounds.MaxX:F3},{basePlotBounds.MaxY:F3}) plot=({plotBounds.MinX:F3},{plotBounds.MinY:F3},{plotBounds.MaxX:F3},{plotBounds.MaxY:F3})");
return plotBounds;
}
static bool IsAnnotationEntityForBounds(string objectName, string layer, string name)
{
var type = objectName.ToLowerInvariant();
if (ContainsAny(type, "dimension", "leader", "mleader", "tolerance", "featurecontrol", "fcf", "surfacefinish", "datum"))
return true;
if (type.EndsWith("text", StringComparison.OrdinalIgnoreCase) ||
ContainsAny(type, "mtext", "attribute", "attdef"))
return true;
if (type.Contains("blockreference", StringComparison.OrdinalIgnoreCase))
return true;
return LooksLikeAnnotationName(layer.ToLowerInvariant()) ||
LooksLikeAnnotationName(name.ToLowerInvariant());
}
static bool TryGetEntityBounds(object entity, out BoundsMm bounds)
{
bounds = new BoundsMm(0, 0, 0, 0);
try
{
object? minPoint = null;
object? maxPoint = null;
object?[] args = [minPoint, maxPoint];
var modifiers = new ParameterModifier(2);
modifiers[0] = true;
modifiers[1] = true;
entity.GetType().InvokeMember(
"GetBoundingBox",
BindingFlags.InvokeMethod,
null,
entity,
args,
[modifiers],
CultureInfo.InvariantCulture,
null);
var min = ToDoubleList(args[0]);
var max = ToDoubleList(args[1]);
if (min.Count < 2 || max.Count < 2)
return false;
bounds = new BoundsMm(
Math.Min(min[0], max[0]),
Math.Min(min[1], max[1]),
Math.Max(min[0], max[0]),
Math.Max(min[1], max[1]));
return bounds.IsFinite;
}
catch
{
return false;
}
}
static bool BoundsNearlyEqual(BoundsMm a, BoundsMm b) =>
Math.Abs(a.MinX - b.MinX) < 1e-6 &&
Math.Abs(a.MinY - b.MinY) < 1e-6 &&
Math.Abs(a.MaxX - b.MaxX) < 1e-6 &&
Math.Abs(a.MaxY - b.MaxY) < 1e-6;
static bool IsAnnotationEntity(object entity, string objectName, string layer, string name, out string reason)
{
var type = objectName.ToLowerInvariant();
if (ContainsAny(type, "dimension", "leader", "mleader", "tolerance", "featurecontrol", "fcf", "surfacefinish", "datum"))
{
reason = "annotation_object_type";
return true;
}
if (type.EndsWith("text", StringComparison.OrdinalIgnoreCase) ||
ContainsAny(type, "mtext", "attribute", "attdef"))
{
reason = "text_annotation";
return true;
}
var layerText = layer.ToLowerInvariant();
if (LooksLikeAnnotationName(layerText))
{
reason = "annotation_layer";
return true;
}
if (type.Contains("blockreference", StringComparison.OrdinalIgnoreCase) &&
LooksLikeAnnotationName(name.ToLowerInvariant()))
{
reason = "annotation_block";
return true;
}
if (type.Contains("blockreference", StringComparison.OrdinalIgnoreCase) &&
!layer.StartsWith("AG4_", StringComparison.OrdinalIgnoreCase))
{
reason = "non_ag4_annotation_block";
return true;
}
if (!layer.StartsWith("AG4_", StringComparison.OrdinalIgnoreCase))
{
if (IsLeaderPrimitive(entity, type))
{
reason = "leader_primitive";
return true;
}
if (IsLeaderDotPrimitive(entity, type))
{
reason = "leader_dot_primitive";
return true;
}
}
reason = "";
return false;
}
static bool IsLeaderPrimitive(object entity, string objectName)
{
if (objectName.Contains("line", StringComparison.OrdinalIgnoreCase) &&
TryReadLineEndpoints(entity, out var start, out var end))
{
return IsLongDiagonalSegment(start.X, start.Y, end.X, end.Y);
}
if (!objectName.Contains("polyline", StringComparison.OrdinalIgnoreCase))
return false;
var coordinates = ToDoubleList(TryGetProperty(entity, "Coordinates"));
if (coordinates.Count < 4)
return false;
for (var i = 0; i + 3 < coordinates.Count; i += 2)
{
if (IsLongDiagonalSegment(coordinates[i], coordinates[i + 1], coordinates[i + 2], coordinates[i + 3]))
return true;
}
return false;
}
static bool IsLeaderDotPrimitive(object entity, string objectName)
{
if (objectName.Contains("point", StringComparison.OrdinalIgnoreCase))
return true;
if (objectName.Contains("circle", StringComparison.OrdinalIgnoreCase))
{
var radius = ReadComDouble(entity, "Radius");
return double.IsFinite(radius) && radius > 0 && radius <= 2.0;
}
return objectName.Contains("hatch", StringComparison.OrdinalIgnoreCase) &&
ReadComDouble(entity, "Area") is var area &&
double.IsFinite(area) &&
area > 0 &&
area <= 12.0;
}
static bool TryReadLineEndpoints(object entity, out Point2 start, out Point2 end)
{
start = default;
end = default;
var startValues = ToDoubleList(TryGetProperty(entity, "StartPoint"));
var endValues = ToDoubleList(TryGetProperty(entity, "EndPoint"));
if (startValues.Count < 2 || endValues.Count < 2)
return false;
start = new Point2(startValues[0], startValues[1]);
end = new Point2(endValues[0], endValues[1]);
return true;
}
static bool IsLongDiagonalSegment(double x1, double y1, double x2, double y2)
{
var dx = x2 - x1;
var dy = y2 - y1;
var length = Math.Sqrt(dx * dx + dy * dy);
if (length < 15.0)
return false;
var angle = Math.Abs(Math.Atan2(dy, dx) * 180.0 / Math.PI);
angle %= 180.0;
var axisAngle = Math.Min(angle, 180.0 - angle);
return axisAngle is >= 8.0 and <= 82.0;
}
static bool LooksLikeAnnotationName(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
return ContainsAny(value,
"dim", "dimension", "anno", "annot", "annotation", "note", "text", "txt",
"tol", "tolerance", "leader", "mleader", "balloon", "bubble", "item",
"center", "centre", "symbol",
"\u6807\u6ce8", "\u5c3a\u5bf8", "\u516c\u5dee", "\u5e8f\u53f7", "\u5f15\u7ebf",
"\u6587\u5b57", "\u6ce8\u91ca", "\u4e2d\u5fc3\u7ebf", "\u7b26\u53f7");
}
static bool ContainsAny(string value, params string[] needles) =>
needles.Any(needle => value.Contains(needle, StringComparison.OrdinalIgnoreCase));
static string ReadComString(object target, string propertyName)
{
try { return GetProperty(target, propertyName)?.ToString() ?? ""; }
catch { return ""; }
}
static double ReadComDouble(object target, string propertyName)
{
try
{
var value = GetProperty(target, propertyName);
return value == null ? double.NaN : Convert.ToDouble(value, CultureInfo.InvariantCulture);
}
catch
{
return double.NaN;
}
}
static object? TryGetProperty(object target, string name)
{
try { return GetProperty(target, name); }
catch { return null; }
}
static List<double> ToDoubleList(object? value)
{
if (value == null)
return [];
if (value is Array array)
{
var result = new List<double>();
foreach (var item in array)
{
if (item != null)
result.Add(Convert.ToDouble(item, CultureInfo.InvariantCulture));
}
return result;
}
return [];
}
static string FirstNonEmpty(params string[] values) =>
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "";
static PlotResult PlotWindowToPng(object doc, string outputPath, BoundsMm bounds, Options options)
{
var layout = GetProperty(doc, "ActiveLayout") ?? throw new InvalidOperationException("ActiveLayout unavailable.");
var plot = GetProperty(doc, "Plot") ?? throw new InvalidOperationException("Plot unavailable.");
try
{
SetProperty(layout, "ConfigName", options.DeviceName);
TryInvoke(layout, "RefreshPlotDeviceInfo");
var media = string.IsNullOrWhiteSpace(options.MediaName)
? SelectLargestPixelMedia(layout)
: options.MediaName;
if (!string.IsNullOrWhiteSpace(media))
SetProperty(layout, "CanonicalMediaName", media);
Invoke(layout, "SetWindowToPlot",
new[] { bounds.MinX, bounds.MinY },
new[] { bounds.MaxX, bounds.MaxY });
SetProperty(layout, "PlotType", 4); // acWindow
SetProperty(layout, "UseStandardScale", true);
SetProperty(layout, "StandardScale", 0); // acScaleToFit
SetProperty(layout, "CenterPlot", true);
SetProperty(layout, "PlotWithPlotStyles", false);
SetProperty(layout, "PlotWithLineweights", true);
SetProperty(layout, "ScaleLineweights", false);
SetProperty(layout, "PlotRotation", bounds.Width >= bounds.Height ? 1 : 0); // ac90degrees / ac0degrees
TrySetProperty(layout, "StyleSheet", "");
TrySetProperty(layout, "PaperUnits", 1); // acMillimeters
if (File.Exists(outputPath))
File.Delete(outputPath);
Console.WriteLine($"plot name={Path.GetFileNameWithoutExtension(outputPath)} device={options.DeviceName} media={media}");
var ok = ToBool(TryInvoke(plot, "PlotToFile", outputPath, options.DeviceName)) ??
ToBool(TryInvoke(plot, "PlotToFile", outputPath)) ??
File.Exists(outputPath);
WaitForFile(outputPath, TimeSpan.FromSeconds(20));
WaitForQuiescent(GetProperty(doc, "Application") ?? doc, TimeSpan.FromSeconds(20));
return new PlotResult(ok && File.Exists(outputPath), options.DeviceName, media);
}
finally
{
ReleaseCom(plot);
ReleaseCom(layout);
}
}
static string SelectLargestPixelMedia(object layout)
{
var names = ToStringList(Invoke(layout, "GetCanonicalMediaNames"));
if (names.Count == 0)
return "";
return names
.Select(name => new { Name = name, Pixels = ParsePixelArea(name) })
.OrderByDescending(item => item.Pixels)
.ThenByDescending(item => item.Name.Length)
.First().Name;
}
static double ParsePixelArea(string mediaName)
{
var matches = Regex.Matches(mediaName, @"\d+(?:\.\d+)?");
if (matches.Count < 2)
return 0;
var values = matches
.Select(match => double.Parse(match.Value, CultureInfo.InvariantCulture))
.ToList();
return values[^1] * values[^2];
}
static List<string> ToStringList(object? value)
{
if (value is null)
return [];
if (value is object[] objects)
return objects.Where(item => item != null).Select(item => item.ToString() ?? "").Where(item => item.Length > 0).ToList();
if (value is Array array)
{
var result = new List<string>();
foreach (var item in array)
{
var text = item?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(text))
result.Add(text);
}
return result;
}
return [];
}
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;
}
static object OpenDocument(object app, string drawingPath, bool readOnly)
{
var documents = GetProperty(app, "Documents") ?? throw new InvalidOperationException("AutoCAD.Application.Documents unavailable.");
try
{
return TryInvoke(documents, "Open", drawingPath, readOnly)
?? TryInvoke(documents, "Open", drawingPath)
?? throw new InvalidOperationException($"AutoCAD failed to open DWG: {drawingPath}");
}
finally
{
ReleaseCom(documents);
}
}
static void WaitForQuiescent(object app, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var state = TryInvoke(app, "GetAcadState");
if (state == null)
return;
try
{
if (ToBool(GetProperty(state, "IsQuiescent")) ?? true)
return;
}
finally
{
ReleaseCom(state);
}
Thread.Sleep(250);
}
}
static void WaitForFile(string path, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (File.Exists(path) && new FileInfo(path).Length > 0)
return;
Thread.Sleep(250);
}
}
static object? Invoke(object target, string name, params object?[] args) =>
RetryCom(() => target.GetType().InvokeMember(name, BindingFlags.InvokeMethod, null, target, args, CultureInfo.InvariantCulture));
static object? TryInvoke(object target, string name, params object?[] args)
{
try { return Invoke(target, name, args); }
catch { return null; }
}
static object? GetProperty(object target, string name) =>
RetryCom(() => target.GetType().InvokeMember(name, BindingFlags.GetProperty, null, target, null, CultureInfo.InvariantCulture));
static void SetProperty(object target, string name, object? value) =>
RetryCom(() =>
{
target.GetType().InvokeMember(name, BindingFlags.SetProperty, null, target, [value], CultureInfo.InvariantCulture);
return true;
});
static void TrySetProperty(object target, string name, object? value)
{
try { SetProperty(target, name, value); }
catch { }
}
static T RetryCom<T>(Func<T> action)
{
Exception? last = null;
for (var attempt = 0; attempt < 20; attempt++)
{
try { return action(); }
catch (TargetInvocationException ex) when (IsRetryableCom(ex.InnerException)) { last = ex.InnerException; }
catch (COMException ex) when (IsRetryableCom(ex)) { last = ex; }
Thread.Sleep(100);
}
throw last ?? new COMException("COM call failed.");
}
static bool IsRetryableCom(Exception? ex) =>
ex is COMException com && (uint)com.ErrorCode is 0x80010001 or 0x8001010A or 0x800AC472;
static bool? ToBool(object? value)
{
if (value == null)
return null;
if (value is bool b)
return b;
if (value is int i)
return i != 0;
return bool.TryParse(value.ToString(), out var parsed) ? parsed : null;
}
static void Safe(Action action)
{
try { action(); }
catch { }
}
static void ReleaseCom(object? obj)
{
if (obj != null && Marshal.IsComObject(obj))
Safe(() => Marshal.FinalReleaseComObject(obj));
}
[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);
sealed record PlotResult(bool Ok, string Device, string Media);
sealed record AnnotationCleanupResult(
int CandidateCount,
int DeletedCount,
Dictionary<string, int> DeletedByObjectName,
List<AnnotationDeleteSample> Samples)
{
public static AnnotationCleanupResult Empty { get; } = new(0, 0, new Dictionary<string, int>(), []);
}
sealed record AnnotationDeleteSample(
string Space,
string Handle,
string ObjectName,
string Layer,
string Name,
string Reason);
readonly record struct Point2(double X, double Y);
sealed record BoundsMm(double MinX, double MinY, double MaxX, double MaxY)
{
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
public bool IsFinite =>
double.IsFinite(MinX) &&
double.IsFinite(MinY) &&
double.IsFinite(MaxX) &&
double.IsFinite(MaxY);
public BoundsMm Expand(double paddingMm) =>
new(MinX - paddingMm, MinY - paddingMm, MaxX + paddingMm, MaxY + paddingMm);
public BoundsMm Union(BoundsMm other) =>
new(
Math.Min(MinX, other.MinX),
Math.Min(MinY, other.MinY),
Math.Max(MaxX, other.MaxX),
Math.Max(MaxY, other.MaxY));
public BoundsMm ClampTo(BoundsMm limit) =>
new(
Math.Max(MinX, limit.MinX),
Math.Max(MinY, limit.MinY),
Math.Min(MaxX, limit.MaxX),
Math.Min(MaxY, limit.MaxY));
public bool Intersects(BoundsMm other) =>
MinX <= other.MaxX &&
MaxX >= other.MinX &&
MinY <= other.MaxY &&
MaxY >= other.MinY;
public double DistanceTo(BoundsMm other)
{
var dx = other.MinX > MaxX ? other.MinX - MaxX : MinX > other.MaxX ? MinX - other.MaxX : 0.0;
var dy = other.MinY > MaxY ? other.MinY - MaxY : MinY > other.MaxY ? MinY - other.MaxY : 0.0;
return Math.Sqrt(dx * dx + dy * dy);
}
}
sealed record ExportRequest(string Name, BoundsMm Bounds, string OutputName);
sealed record HighlightBoxRequest(string Name, BoundsMm Bounds);
sealed record HighlightResult(int RequestedCount, int HighlightedCount, int BoxCount, List<string> FailedHandles)
{
public static HighlightResult Empty { get; } = new(0, 0, 0, []);
}
sealed class Options
{
public string DrawingPath { get; private set; } = "";
public string WorkingDwgPath { get; set; } = "";
public string OutputDir { get; private set; } = "";
public string ReportPath { get; private set; } = "";
public string ProgId { get; private set; } = "AutoCAD.Application.23.1";
public string DeviceName { get; private set; } = "PublishToWeb PNG.pc3";
public string MediaName { get; private set; } = "";
public double PaddingMm { get; private set; } = 5.0;
public bool Visible { get; private set; }
public bool CloseDocument { get; private set; } = true;
public bool RemoveAnnotations { get; private set; }
public bool IncludeAnnotationsInBounds { get; private set; }
public double AnnotationConnectToleranceMm { get; private set; } = 2.0;
public double MaxAnnotationExpansionMm { get; private set; } = 80.0;
public double MaxAnnotationBoxSizeMm { get; private set; } = 220.0;
public double ViewSeedToleranceMm { get; private set; } = 0.5;
public int MaxCleanupSamples { get; private set; } = 100;
public int HighlightColorIndex { get; private set; } = 1;
public int HighlightLineweight { get; private set; } = 70;
public double HighlightBoxPaddingMm { get; private set; } = 2.0;
public List<string> HighlightHandles { get; } = [];
public List<HighlightBoxRequest> HighlightBoxes { get; } = [];
public bool HasHighlights => HighlightHandles.Count > 0 || HighlightBoxes.Count > 0;
public List<ExportRequest> Exports { get; } = [];
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 "--drawing-path":
case "--source-dwg":
options.DrawingPath = Next();
break;
case "--output-dir":
options.OutputDir = Next();
break;
case "--working-dwg":
case "--copy-dwg-to":
options.WorkingDwgPath = Next();
break;
case "--report":
options.ReportPath = Next();
break;
case "--prog-id":
options.ProgId = Next();
break;
case "--device":
case "--plotter":
options.DeviceName = Next();
break;
case "--media":
options.MediaName = Next();
break;
case "--padding-mm":
options.PaddingMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
break;
case "--remove-annotations":
case "--clean-annotations":
options.RemoveAnnotations = true;
break;
case "--include-annotations-in-bounds":
case "--expand-bounds-to-connected-annotations":
options.IncludeAnnotationsInBounds = true;
break;
case "--annotation-connect-tolerance-mm":
options.AnnotationConnectToleranceMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
break;
case "--max-annotation-expansion-mm":
options.MaxAnnotationExpansionMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
break;
case "--max-annotation-box-size-mm":
options.MaxAnnotationBoxSizeMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
break;
case "--view-seed-tolerance-mm":
options.ViewSeedToleranceMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
break;
case "--max-cleanup-samples":
options.MaxCleanupSamples = Math.Max(0, int.Parse(Next(), CultureInfo.InvariantCulture));
break;
case "--highlight-handles":
options.HighlightHandles.AddRange(ParseHandles(Next()));
break;
case "--highlight-color-index":
options.HighlightColorIndex = Math.Max(1, int.Parse(Next(), CultureInfo.InvariantCulture));
break;
case "--highlight-lineweight":
options.HighlightLineweight = Math.Max(0, int.Parse(Next(), CultureInfo.InvariantCulture));
break;
case "--highlight-boxes-mm":
options.HighlightBoxes.Add(ParseHighlightBox(Next(), options.HighlightBoxes.Count + 1));
break;
case "--highlight-box-padding-mm":
options.HighlightBoxPaddingMm = Math.Max(0, double.Parse(Next(), CultureInfo.InvariantCulture));
break;
case "--visible":
options.Visible = true;
break;
case "--no-close":
options.CloseDocument = false;
break;
case "--view-bounds-mm":
options.Exports.Add(ParseExport(Next(), options.Exports.Count + 1));
break;
}
}
if (string.IsNullOrWhiteSpace(options.DrawingPath))
throw new ArgumentException("--drawing-path is required.");
if (string.IsNullOrWhiteSpace(options.OutputDir))
options.OutputDir = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(options.DrawingPath)) ?? ".", "view-png");
return options;
}
static ExportRequest ParseExport(string value, int index)
{
var name = $"view-{index:00}";
var boundsText = value;
var nameSeparator = value.IndexOf('=', StringComparison.Ordinal);
if (nameSeparator > 0)
{
name = SanitizeFileName(value[..nameSeparator].Trim());
boundsText = value[(nameSeparator + 1)..];
}
var parts = boundsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length != 4)
throw new ArgumentException("--view-bounds-mm requires optionalName=minX,minY,maxX,maxY.");
var bounds = new BoundsMm(
double.Parse(parts[0], CultureInfo.InvariantCulture),
double.Parse(parts[1], CultureInfo.InvariantCulture),
double.Parse(parts[2], CultureInfo.InvariantCulture),
double.Parse(parts[3], CultureInfo.InvariantCulture));
return new ExportRequest(name, bounds, name + ".png");
}
static HighlightBoxRequest ParseHighlightBox(string value, int index)
{
var export = ParseExport(value, index);
return new HighlightBoxRequest(export.Name, export.Bounds);
}
static string SanitizeFileName(string value)
{
var invalid = Path.GetInvalidFileNameChars().ToHashSet();
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
var result = new string(chars).Trim();
return string.IsNullOrWhiteSpace(result) ? "view" : result;
}
static IEnumerable<string> ParseHandles(string value) =>
value.Split(new[] { ',', ';', '|', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase);
}