1146 lines
39 KiB
C#
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);
|
|
}
|