first commit

This commit is contained in:
2026-07-17 17:45:56 +08:00
commit 04c487823b
5873 changed files with 12227228 additions and 0 deletions
@@ -0,0 +1,657 @@
using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
using Microsoft.Win32;
var options = CliOptions.Parse(args);
if (!options.HasReferenceCenter && (string.IsNullOrWhiteSpace(options.ReferenceDwgPath) || !File.Exists(options.ReferenceDwgPath)))
throw new FileNotFoundException($"参考 DWG 不存在: {options.ReferenceDwgPath}");
if (string.IsNullOrWhiteSpace(options.StudentDwgPath) || !File.Exists(options.StudentDwgPath))
throw new FileNotFoundException($"学生 DWG 不存在: {options.StudentDwgPath}");
if (string.IsNullOrWhiteSpace(options.OutputDwgPath))
throw new ArgumentException("缺少 --output-dwg。为避免破坏原始学生图,必须输出到副本。");
Directory.CreateDirectory(Path.GetDirectoryName(options.OutputDwgPath) ?? Environment.CurrentDirectory);
var result = StaRunner.Run(() => DwgSheetFrameAligner.Align(options));
var reportPath = Path.Combine(
Path.GetDirectoryName(options.OutputDwgPath) ?? Environment.CurrentDirectory,
Path.GetFileNameWithoutExtension(options.OutputDwgPath) + ".align-report.json");
File.WriteAllText(reportPath, JsonSerializer.Serialize(result, JsonDefaults.Options));
Console.WriteLine(JsonSerializer.Serialize(result, JsonDefaults.Options));
sealed class DwgSheetFrameAligner
{
const double AxisTolerance = 0.05;
const double FrameTolerance = 1.0;
[DllImport("ole32.dll", CharSet = CharSet.Unicode)]
private static extern int CLSIDFromProgID(string progId, out Guid clsid);
[DllImport("oleaut32.dll", PreserveSig = false)]
[return: MarshalAs(UnmanagedType.IUnknown)]
private static extern object GetActiveObject(ref Guid clsid, IntPtr reserved);
public static AlignResult Align(CliOptions options)
{
var started = DateTimeOffset.Now;
var app = ConnectOrCreate(options.ProgId, options.Visible, out _);
object? referenceDoc = null;
object? studentDoc = null;
try
{
FrameRect referenceFrame;
if (options.HasReferenceCenter)
{
referenceFrame = FrameRect.FromCenter(options.ReferenceCenterX!.Value, options.ReferenceCenterY!.Value);
}
else
{
WaitForQuiescent(app, TimeSpan.FromSeconds(30));
referenceDoc = OpenDocument(app, options.ReferenceDwgPath, readOnly: true);
referenceFrame = DetectInnerFrame(referenceDoc);
TryInvoke(referenceDoc, "Close", [false]);
ReleaseCom(referenceDoc);
referenceDoc = null;
}
File.Copy(options.StudentDwgPath, options.OutputDwgPath, overwrite: true);
WaitForQuiescent(app, TimeSpan.FromSeconds(30));
studentDoc = OpenDocument(app, options.OutputDwgPath, readOnly: false);
var studentFrameBefore = DetectInnerFrame(studentDoc);
var dx = referenceFrame.CenterX - studentFrameBefore.CenterX;
var dy = referenceFrame.CenterY - studentFrameBefore.CenterY;
var moved = MoveAllEntities(studentDoc, dx, dy);
TryInvoke(studentDoc, "Save", []);
var studentFrameAfter = DetectInnerFrame(studentDoc);
if (options.CloseAfterAlign)
TryInvoke(studentDoc, "Close", [false]);
return new AlignResult
{
Ok = true,
StartedAt = started,
CompletedAt = DateTimeOffset.Now,
ReferenceDwgPath = options.ReferenceDwgPath,
StudentDwgPath = options.StudentDwgPath,
OutputDwgPath = options.OutputDwgPath,
ReferenceInnerFrame = referenceFrame,
StudentInnerFrameBefore = studentFrameBefore,
StudentInnerFrameAfter = studentFrameAfter,
DeltaX = dx,
DeltaY = dy,
MovedEntityCount = moved,
Message = "已按图框内圈中心整体平移学生 DWG 副本。"
};
}
finally
{
if (referenceDoc != null)
{
TryInvoke(referenceDoc, "Close", [false]);
ReleaseCom(referenceDoc);
}
if (studentDoc != null)
ReleaseCom(studentDoc);
ReleaseCom(app);
}
}
static FrameRect DetectInnerFrame(object doc)
{
var lines = new List<Line2d>();
lines.AddRange(ReadLines(doc, "ModelSpace"));
lines.AddRange(ReadLines(doc, "PaperSpace"));
if (lines.Count == 0)
throw new InvalidOperationException("DWG 中没有可用于识别图框的直线。");
var horizontal = lines
.Where(line => Math.Abs(line.Y1 - line.Y2) <= AxisTolerance)
.Select(line => line.Normalized())
.ToList();
var vertical = lines
.Where(line => Math.Abs(line.X1 - line.X2) <= AxisTolerance)
.Select(line => line.Normalized())
.ToList();
var maxHorizontal = horizontal.Select(line => line.Length).DefaultIfEmpty(0).Max();
var maxVertical = vertical.Select(line => line.Length).DefaultIfEmpty(0).Max();
var longHorizontal = horizontal
.Where(line => line.Length >= maxHorizontal * 0.65)
.OrderByDescending(line => line.Length)
.Take(20)
.ToList();
var longVertical = vertical
.Where(line => line.Length >= maxVertical * 0.65)
.OrderByDescending(line => line.Length)
.Take(20)
.ToList();
var rectangles = new List<FrameRect>();
for (var hi = 0; hi < longHorizontal.Count; hi++)
{
for (var hj = hi + 1; hj < longHorizontal.Count; hj++)
{
var bottom = longHorizontal[hi].Y1 <= longHorizontal[hj].Y1 ? longHorizontal[hi] : longHorizontal[hj];
var top = ReferenceEquals(bottom, longHorizontal[hi]) ? longHorizontal[hj] : longHorizontal[hi];
for (var vi = 0; vi < longVertical.Count; vi++)
{
for (var vj = vi + 1; vj < longVertical.Count; vj++)
{
var left = longVertical[vi].X1 <= longVertical[vj].X1 ? longVertical[vi] : longVertical[vj];
var right = ReferenceEquals(left, longVertical[vi]) ? longVertical[vj] : longVertical[vi];
if (!SpansX(bottom, left.X1, right.X1) || !SpansX(top, left.X1, right.X1))
continue;
if (!SpansY(left, bottom.Y1, top.Y1) || !SpansY(right, bottom.Y1, top.Y1))
continue;
rectangles.Add(new FrameRect
{
MinX = left.X1,
MinY = bottom.Y1,
MaxX = right.X1,
MaxY = top.Y1,
SourceHandles =
[
bottom.Handle,
top.Handle,
left.Handle,
right.Handle
]
});
}
}
}
}
var unique = rectangles
.GroupBy(rect => $"{Math.Round(rect.MinX, 3)}|{Math.Round(rect.MinY, 3)}|{Math.Round(rect.MaxX, 3)}|{Math.Round(rect.MaxY, 3)}")
.Select(group => group.First())
.Where(rect => rect.Width > 0 && rect.Height > 0)
.OrderByDescending(rect => rect.Area)
.ToList();
if (unique.Count == 0)
throw new InvalidOperationException("未能识别图框矩形。");
// 工程图一般有外框和内框;面积最大的是外框,第二大的是内框。
return unique.Count >= 2 ? unique[1] : unique[0];
}
static bool SpansX(Line2d line, double left, double right) =>
line.MinX <= left + FrameTolerance && line.MaxX >= right - FrameTolerance;
static bool SpansY(Line2d line, double bottom, double top) =>
line.MinY <= bottom + FrameTolerance && line.MaxY >= top - FrameTolerance;
static List<Line2d> ReadLines(object doc, string spaceName)
{
var result = new List<Line2d>();
var space = GetProperty(doc, spaceName);
if (space == null)
return result;
try
{
var count = ToInt(GetProperty(space, "Count")) ?? 0;
for (var i = 0; i < count; i++)
{
var entity = GetCollectionItem(space, i);
if (entity == null)
continue;
try
{
var objectName = ReadString(entity, "ObjectName");
if (!string.Equals(objectName, "AcDbLine", StringComparison.OrdinalIgnoreCase))
continue;
var start = DoubleList(GetProperty(entity, "StartPoint"));
var end = DoubleList(GetProperty(entity, "EndPoint"));
if (start.Count < 2 || end.Count < 2)
continue;
result.Add(new Line2d(
start[0],
start[1],
end[0],
end[1],
ReadString(entity, "Handle"),
spaceName));
}
finally
{
ReleaseCom(entity);
}
}
}
finally
{
ReleaseCom(space);
}
return result;
}
static int MoveAllEntities(object doc, double dx, double dy)
{
var moved = 0;
moved += MoveSpaceEntities(doc, "ModelSpace", dx, dy);
moved += MoveSpaceEntities(doc, "PaperSpace", dx, dy);
return moved;
}
static int MoveSpaceEntities(object doc, string spaceName, double dx, double dy)
{
var moved = 0;
var space = GetProperty(doc, spaceName);
if (space == null)
return moved;
try
{
var count = ToInt(GetProperty(space, "Count")) ?? 0;
var from = new double[] { 0, 0, 0 };
var to = new double[] { dx, dy, 0 };
for (var i = 0; i < count; i++)
{
var entity = GetCollectionItem(space, i);
if (entity == null)
continue;
try
{
TryInvoke(entity, "Move", [from, to]);
moved++;
}
finally
{
ReleaseCom(entity);
}
}
}
finally
{
ReleaseCom(space);
}
return moved;
}
static object ConnectOrCreate(string progId, bool visible, out DateTimeOffset started)
{
started = DateTimeOffset.Now;
object? app = null;
try
{
var type = Type.GetTypeFromProgID(progId);
if (type != null)
app = GetActiveObjectFromProgId(progId);
}
catch
{
app = null;
}
if (app == null)
{
StartAutoCadFromRegistry(progId);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60);
while (DateTime.UtcNow < deadline && app == null)
{
try { app = GetActiveObjectFromProgId(progId); }
catch { Thread.Sleep(500); }
}
}
if (app == null)
throw new InvalidOperationException($"AutoCAD 启动或 COM 连接失败: {progId}");
TrySetProperty(app, "Visible", visible);
return app;
}
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。ProgID={progId}, LocalServer32={command}");
Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = "/Automation",
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(exe) ?? Environment.CurrentDirectory
});
}
static string ReadDefaultValue(string subKey)
{
using var key = Registry.ClassesRoot.OpenSubKey(subKey);
return key?.GetValue("")?.ToString() ?? "";
}
static string ExtractExePath(string command)
{
if (string.IsNullOrWhiteSpace(command))
return "";
var trimmed = command.Trim();
if (trimmed.StartsWith('"'))
{
var end = trimmed.IndexOf('"', 1);
return end > 1 ? trimmed[1..end] : "";
}
var exeIndex = trimmed.IndexOf(".exe", StringComparison.OrdinalIgnoreCase);
return exeIndex >= 0 ? trimmed[..(exeIndex + 4)] : trimmed;
}
static object OpenDocument(object app, string drawingPath, bool readOnly)
{
var documents = GetProperty(app, "Documents") ?? throw new InvalidOperationException("AutoCAD.Application.Documents 不可用。");
try
{
return TryInvoke(documents, "Open", [drawingPath, readOnly])
?? TryInvoke(documents, "Open", [drawingPath])
?? throw new InvalidOperationException($"AutoCAD 打开 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 object? GetProperty(object obj, string name) => RetryCom(() =>
{
try
{
return obj.GetType().InvokeMember(name, BindingFlags.GetProperty, null, obj, null, CultureInfo.InvariantCulture);
}
catch (TargetInvocationException ex) when (IsRetryableCom(ex.InnerException)) { throw; }
catch (COMException ex) when (IsRetryableCom(ex)) { throw; }
catch { return null; }
});
static object? GetCollectionItem(object obj, int index) => RetryCom(() =>
{
foreach (var flags in new[] { BindingFlags.GetProperty, BindingFlags.InvokeMethod })
{
try { return obj.GetType().InvokeMember("Item", flags, null, obj, [index], CultureInfo.InvariantCulture); }
catch (TargetInvocationException ex) when (IsRetryableCom(ex.InnerException)) { throw; }
catch (COMException ex) when (IsRetryableCom(ex)) { throw; }
catch { }
}
return null;
});
static object? TryInvoke(object obj, string name, object?[] args) => RetryCom(() =>
{
try { return obj.GetType().InvokeMember(name, BindingFlags.InvokeMethod, null, obj, args, CultureInfo.InvariantCulture); }
catch (TargetInvocationException ex) when (IsRetryableCom(ex.InnerException)) { throw; }
catch (COMException ex) when (IsRetryableCom(ex)) { throw; }
catch { return null; }
});
static void TrySetProperty(object obj, string name, object? value)
{
RetryCom(() =>
{
try { obj.GetType().InvokeMember(name, BindingFlags.SetProperty, null, obj, [value], CultureInfo.InvariantCulture); }
catch { }
return null;
});
}
static object? RetryCom(Func<object?> action)
{
Exception? last = null;
for (var i = 0; i < 180; i++)
{
try { return action(); }
catch (TargetInvocationException ex) when (IsRetryableCom(ex.InnerException))
{
last = ex.InnerException;
Thread.Sleep(100);
}
catch (COMException ex) when (IsRetryableCom(ex))
{
last = ex;
Thread.Sleep(100);
}
}
if (last != null)
throw last;
return action();
}
static bool IsRetryableCom(Exception? ex) =>
ex is COMException com && unchecked((uint)com.HResult) is 0x80010001 or 0x8001010A;
static string ReadString(object obj, string name) => Convert.ToString(GetProperty(obj, name), CultureInfo.InvariantCulture) ?? "";
static int? ToInt(object? value)
{
if (value == null)
return null;
return int.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
static bool? ToBool(object? value)
{
if (value == null)
return null;
if (value is bool b)
return b;
return bool.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), out var parsed) ? parsed : null;
}
static List<double> DoubleList(object? value)
{
if (value == null)
return [];
if (value is double[] doubles)
return doubles.ToList();
if (value is object[] objects)
return objects.SelectMany(DoubleList).ToList();
if (value is Array array)
{
var result = new List<double>();
foreach (var item in array)
{
if (double.TryParse(Convert.ToString(item, CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
result.Add(parsed);
}
return result;
}
return [];
}
static void ReleaseCom(object? obj)
{
if (obj != null && Marshal.IsComObject(obj))
Marshal.ReleaseComObject(obj);
}
}
sealed class CliOptions
{
public string ReferenceDwgPath { get; set; } = "";
public string StudentDwgPath { get; set; } = "";
public string OutputDwgPath { get; set; } = "";
public string ProgId { get; set; } = "AutoCAD.Application.23.1";
public double? ReferenceCenterX { get; set; }
public double? ReferenceCenterY { get; set; }
public bool HasReferenceCenter => ReferenceCenterX != null && ReferenceCenterY != null;
public bool Visible { get; set; } = true;
public bool CloseAfterAlign { get; set; } = true;
public static CliOptions Parse(string[] args)
{
var options = new CliOptions();
for (var i = 0; i < args.Length; i++)
{
var key = args[i];
var value = i + 1 < args.Length ? args[i + 1] : "";
switch (key)
{
case "--reference-dwg":
options.ReferenceDwgPath = Path.GetFullPath(value.Trim().Trim('"'));
i++;
break;
case "--student-dwg":
options.StudentDwgPath = Path.GetFullPath(value.Trim().Trim('"'));
i++;
break;
case "--output-dwg":
options.OutputDwgPath = Path.GetFullPath(value.Trim().Trim('"'));
i++;
break;
case "--reference-center-x":
options.ReferenceCenterX = double.Parse(value, CultureInfo.InvariantCulture);
i++;
break;
case "--reference-center-y":
options.ReferenceCenterY = double.Parse(value, CultureInfo.InvariantCulture);
i++;
break;
case "--prog-id":
options.ProgId = value;
i++;
break;
case "--visible":
options.Visible = bool.TryParse(value, out var visible) ? visible : options.Visible;
i++;
break;
case "--close-after-align":
options.CloseAfterAlign = bool.TryParse(value, out var close) ? close : options.CloseAfterAlign;
i++;
break;
}
}
return options;
}
}
sealed record Line2d(double X1, double Y1, double X2, double Y2, string Handle, string SpaceName)
{
public double Length => Math.Sqrt(Math.Pow(X2 - X1, 2) + Math.Pow(Y2 - Y1, 2));
public double MinX => Math.Min(X1, X2);
public double MaxX => Math.Max(X1, X2);
public double MinY => Math.Min(Y1, Y2);
public double MaxY => Math.Max(Y1, Y2);
public Line2d Normalized()
{
if (Math.Abs(Y1 - Y2) <= Math.Abs(X1 - X2))
return X1 <= X2 ? this : this with { X1 = X2, Y1 = Y2, X2 = X1, Y2 = Y1 };
return Y1 <= Y2 ? this : this with { X1 = X2, Y1 = Y2, X2 = X1, Y2 = Y1 };
}
}
sealed class FrameRect
{
public double MinX { get; set; }
public double MinY { get; set; }
public double MaxX { get; set; }
public double MaxY { get; set; }
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
public double Area => Width * Height;
public double CenterX => (MinX + MaxX) / 2.0;
public double CenterY => (MinY + MaxY) / 2.0;
public List<string> SourceHandles { get; set; } = [];
public static FrameRect FromCenter(double x, double y)
{
return new FrameRect
{
MinX = x,
MaxX = x,
MinY = y,
MaxY = y,
SourceHandles = ["explicit_reference_center"]
};
}
}
sealed class AlignResult
{
public bool Ok { get; set; }
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset CompletedAt { get; set; }
public string ReferenceDwgPath { get; set; } = "";
public string StudentDwgPath { get; set; } = "";
public string OutputDwgPath { get; set; } = "";
public FrameRect ReferenceInnerFrame { get; set; } = new();
public FrameRect StudentInnerFrameBefore { get; set; } = new();
public FrameRect StudentInnerFrameAfter { get; set; } = new();
public double DeltaX { get; set; }
public double DeltaY { get; set; }
public int MovedEntityCount { get; set; }
public string Message { get; set; } = "";
}
static class StaRunner
{
public static T Run<T>(Func<T> action)
{
T? result = default;
Exception? error = null;
var thread = new Thread(() =>
{
try { result = action(); }
catch (Exception ex) { error = ex; }
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (error != null)
throw error;
return result!;
}
}
static class JsonDefaults
{
public static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
}