refactor(core): унификация COM-хелперов и классификаторов топологии

Вынесены общие ComHelper (Release + Safe-геттеры) и GeomClassifiers
(FaceType/EdgeType), устранены дубли в 5+ сервисах. Освобождение doc3d RCW
в GetTopPart, void-возвраты вместо фиктивных bool, HashSet для OpKinds,
DocumentTypeName в паспорте модели, FileExtension без аллокаций.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 20:15:43 +03:00
parent e1337e4016
commit 579b191d7c
13 changed files with 229 additions and 188 deletions
@@ -0,0 +1,39 @@
using Kompas6API5;
namespace Kompas.Mcp.Core.Query;
/// <summary>
/// Классификаторы топологических объектов КОМПАС: строковый тип грани и ребра.
/// <para>
/// Каждый вызов COM-метода обёрнут в отдельный <c>try/catch</c> — на экзотической геометрии
/// (NURBS, импортированные STEP-тела) методы класса <c>ksFaceDefinition</c>/<c>ksEdgeDefinition</c>
/// могут бросить <c>COMException</c>, что не должно прерывать bulk-перебор. Ранее этот код
/// был продублирован в <c>QueryService</c> (без защиты) и <c>ModelInspectionService</c> (с защитой).
/// </para>
/// </summary>
internal static class GeomClassifiers
{
/// <summary>Строковый тип поверхности: plane/cylinder/cone/sphere/torus/nurbs/other.</summary>
internal static string FaceType(ksFaceDefinition d)
{
try { if (d.IsPlanar()) return "plane"; } catch { }
try { if (d.IsCylinder()) return "cylinder"; } catch { }
try { if (d.IsCone()) return "cone"; } catch { }
try { if (d.IsSphere()) return "sphere"; } catch { }
try { if (d.IsTorus()) return "torus"; } catch { }
try { if (d.IsNurbsSurface()) return "nurbs"; } catch { }
return "other";
}
/// <summary>Строковый тип кривой: line/circle/arc/ellipse_arc/ellipse/nurbs/other.</summary>
internal static string EdgeType(ksEdgeDefinition d)
{
try { if (d.IsLineSeg()) return "line"; } catch { }
try { if (d.IsCircle()) return "circle"; } catch { }
try { if (d.IsArc()) return "arc"; } catch { }
try { if (d.IsEllipseArc()) return "ellipse_arc"; } catch { }
try { if (d.IsEllipse()) return "ellipse"; } catch { }
try { if (d.IsNurbs()) return "nurbs"; } catch { }
return "other";
}
}
+10 -6
View File
@@ -23,8 +23,9 @@ public static class InspectionText
}
// Формообразующие операции, наличие которых означает параметрическую историю.
private static readonly string[] OpKinds =
{ "sketch", "extrusion", "cut_extrusion", "revolve", "cut_revolve", "fillet", "chamfer" };
// HashSet<string> даёт O(1) Contains вместо O(n) по массиву при bulk-проверке граней.
private static readonly HashSet<string> OpKinds =
new(StringComparer.Ordinal) { "sketch", "extrusion", "cut_extrusion", "revolve", "cut_revolve", "fillet", "chamfer" };
/// <summary>
/// Модель — «голый» импорт без истории построения (типично для STEP): в дереве только начало
@@ -39,18 +40,21 @@ public static class InspectionText
public static string GroupTypes(IReadOnlyList<string> types)
{
if (types.Count == 0) return "—";
// Count материализуем один раз: IGrouping<K,V> — IEnumerable без кеша,
// двойной вызов g.Count() итерирует группу дважды.
return string.Join(", ", types
.GroupBy(t => t)
.OrderByDescending(g => g.Count())
.ThenBy(g => g.Key, StringComparer.Ordinal)
.Select(g => $"{g.Count()} {g.Key}"));
.Select(g => (Key: g.Key, Count: g.Count()))
.OrderByDescending(x => x.Count)
.ThenBy(x => x.Key, StringComparer.Ordinal)
.Select(x => $"{x.Count} {x.Key}"));
}
/// <summary>Отрендерить «паспорт» модели в читаемый текст.</summary>
public static string Render(ModelDescription d)
{
var sb = new StringBuilder();
sb.AppendLine($"Документ: «{d.Name}» (деталь)");
sb.AppendLine($"Документ: «{d.Name}» ({d.DocumentTypeName})");
if (d.IsEmpty)
{
@@ -38,4 +38,10 @@ public sealed record ModelDescription
/// <summary>Переменные модели.</summary>
public required IReadOnlyList<VariableInfo> Variables { get; init; }
/// <summary>
/// Локализованное название типа документа для вывода: «деталь», «сборка», «чертёж» и т.д.
/// По умолчанию «деталь».
/// </summary>
public string DocumentTypeName { get; init; } = "деталь";
}
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Threading;
using Kompas6API5;
using Kompas6Constants3D;
@@ -34,7 +36,7 @@ public sealed class ModelInspectionService
var part = GetTopPart();
try
{
var name = ActiveDocName();
var (name, typeName) = ActiveDocInfo();
bool hasGab = part.GetGabarit(false, false,
out double x1, out double y1, out double z1, out double x2, out double y2, out double z2);
@@ -48,6 +50,7 @@ public sealed class ModelInspectionService
Bodies = Array.Empty<BodyInfo>(), FaceTypes = Array.Empty<string>(),
EdgeTypes = Array.Empty<string>(), Features = Array.Empty<FeatureInfo>(),
Imported = false, Variables = Array.Empty<VariableInfo>(),
DocumentTypeName = typeName,
};
var bodies = ReadBodies(part);
@@ -67,9 +70,10 @@ public sealed class ModelInspectionService
Features = features,
Imported = InspectionText.IsImported(features, bodies.Count),
Variables = variables,
DocumentTypeName = typeName,
};
}
finally { Release(part); }
finally { ComHelper.Release(part); }
}, ct);
/// <summary>Перечислить узлы дерева построения по порядку.</summary>
@@ -78,7 +82,7 @@ public sealed class ModelInspectionService
{
var part = GetTopPart();
try { return ReadFeatures(part); }
finally { Release(part); }
finally { ComHelper.Release(part); }
}, ct);
/// <summary>Перечислить тела детали.</summary>
@@ -87,7 +91,7 @@ public sealed class ModelInspectionService
{
var part = GetTopPart();
try { return ReadBodies(part); }
finally { Release(part); }
finally { ComHelper.Release(part); }
}, ct);
/// <summary>Перечислить переменные модели.</summary>
@@ -96,7 +100,7 @@ public sealed class ModelInspectionService
{
var part = GetTopPart();
try { return ReadVariables(part); }
finally { Release(part); }
finally { ComHelper.Release(part); }
}, ct);
/// <summary>Детали грани по индексу из list_faces: тип, площадь, нормаль, радиус, число рёбер.</summary>
@@ -117,14 +121,14 @@ public sealed class ModelInspectionService
return new FaceDetail
{
Index = index,
Type = FaceType(def),
Type = GeomClassifiers.FaceType(def),
Area = area,
Normal = FaceNormal(def),
Radius = radius,
EdgeCount = edgeCount,
};
}
finally { Release(part); }
finally { ComHelper.Release(part); }
}, ct);
/// <summary>Детали ребра по индексу из list_edges: тип, длина, смежные грани, концевые вершины.</summary>
@@ -139,13 +143,13 @@ public sealed class ModelInspectionService
double len = 0; try { len = def.GetLength(MixMm); } catch { }
string? f1 = null, f2 = null;
try { if (def.GetAdjacentFace(true) is ksFaceDefinition a) f1 = FaceType(a); } catch { }
try { if (def.GetAdjacentFace(false) is ksFaceDefinition b) f2 = FaceType(b); } catch { }
try { if (def.GetAdjacentFace(true) is ksFaceDefinition a) f1 = GeomClassifiers.FaceType(a); } catch { }
try { if (def.GetAdjacentFace(false) is ksFaceDefinition b) f2 = GeomClassifiers.FaceType(b); } catch { }
return new EdgeDetail
{
Index = index,
Type = EdgeType(def),
Type = GeomClassifiers.EdgeType(def),
Length = len,
AdjacentFace1 = f1,
AdjacentFace2 = f2,
@@ -153,7 +157,7 @@ public sealed class ModelInspectionService
End = Vertex(def, false),
};
}
finally { Release(part); }
finally { ComHelper.Release(part); }
}, ct);
/// <summary>
@@ -186,7 +190,7 @@ public sealed class ModelInspectionService
AngleValid = angleValid,
};
}
finally { Release(part); }
finally { ComHelper.Release(part); }
}, ct);
// ── чтение данных (на STA-потоке) ─────────────────────────────────────
@@ -264,11 +268,11 @@ public sealed class ModelInspectionService
if (vc.GetByIndex(i) is not ksVariable v) continue;
list.Add(new VariableInfo
{
Name = SafeStr(() => v.name),
Expression = SafeStr(() => v.Expression),
Value = SafeNum(() => v.value),
External = SafeBool(() => v.external),
Information = SafeBool(() => v.Information),
Name = ComHelper.SafeStr(() => v.name),
Expression = ComHelper.SafeStr(() => v.Expression),
Value = ComHelper.SafeNum(() => v.value),
External = ComHelper.SafeBool(() => v.external),
Information = ComHelper.SafeBool(() => v.Information),
});
}
return list;
@@ -280,10 +284,12 @@ public sealed class ModelInspectionService
var edgeTypes = new List<string>();
if (Faces(part) is { } faces)
for (int i = 0; i < faces.GetCount(); i++)
if (faces.GetByIndex(i) is ksEntity e && e.GetDefinition() is ksFaceDefinition d) faceTypes.Add(FaceType(d));
if (faces.GetByIndex(i) is ksEntity e && e.GetDefinition() is ksFaceDefinition d)
faceTypes.Add(GeomClassifiers.FaceType(d));
if (Edges(part) is { } edges)
for (int i = 0; i < edges.GetCount(); i++)
if (edges.GetByIndex(i) is ksEntity e && e.GetDefinition() is ksEdgeDefinition d) edgeTypes.Add(EdgeType(d));
if (edges.GetByIndex(i) is ksEntity e && e.GetDefinition() is ksEdgeDefinition d)
edgeTypes.Add(GeomClassifiers.EdgeType(d));
return (faceTypes, edgeTypes);
}
@@ -349,16 +355,43 @@ public sealed class ModelInspectionService
{
var doc3d = _session.Kompas.ActiveDocument3D() as ksDocument3D
?? throw new InvalidOperationException("Нет активного 3D-документа.");
return doc3d.GetPart((short)Part_Type.pTop_Part) as ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
// Освобождаем RCW документа сразу после получения ksPart — part имеет собственный ref.
try
{
return doc3d.GetPart((short)Part_Type.pTop_Part) as ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
}
finally { ComHelper.Release(doc3d); }
}
private string ActiveDocName()
/// <summary>Имя и локализованный тип активного документа. Отдельный API7-вызов от GetTopPart.</summary>
private (string name, string typeName) ActiveDocInfo()
{
var doc = _session.Application.ActiveDocument;
try { return doc?.Name ?? ""; }
catch { return ""; }
finally { Release(doc); }
try
{
string name = ComHelper.SafeStr(() => doc?.Name);
string typeName = "деталь";
if (doc is not null)
{
try
{
typeName = DocumentTypes.FromEnum(doc.DocumentType) switch
{
KompasDocumentType.Assembly => "сборка",
KompasDocumentType.Drawing => "чертёж",
KompasDocumentType.Fragment => "фрагмент",
KompasDocumentType.Specification => "спецификация",
KompasDocumentType.Text => "текст",
_ => "деталь",
};
}
catch { }
}
return (name, typeName);
}
catch { return ("", "деталь"); }
finally { ComHelper.Release(doc); }
}
private static ksEntityCollection? Faces(ksPart part) => part.EntityCollection((short)Obj3dType.o3d_face) as ksEntityCollection;
@@ -382,44 +415,11 @@ public sealed class ModelInspectionService
?? throw new InvalidOperationException($"Ребро[{index}] не приводится к ksEdgeDefinition.");
}
private static string FaceType(ksFaceDefinition d)
{
try { if (d.IsPlanar()) return "plane"; } catch { }
try { if (d.IsCylinder()) return "cylinder"; } catch { }
try { if (d.IsCone()) return "cone"; } catch { }
try { if (d.IsSphere()) return "sphere"; } catch { }
try { if (d.IsTorus()) return "torus"; } catch { }
try { if (d.IsNurbsSurface()) return "nurbs"; } catch { }
return "other";
}
private static string EdgeType(ksEdgeDefinition d)
{
try { if (d.IsLineSeg()) return "line"; } catch { }
try { if (d.IsCircle()) return "circle"; } catch { }
try { if (d.IsArc()) return "arc"; } catch { }
try { if (d.IsEllipseArc()) return "ellipse_arc"; } catch { }
try { if (d.IsEllipse()) return "ellipse"; } catch { }
try { if (d.IsNurbs()) return "nurbs"; } catch { }
return "other";
}
private static BoundingBox Box(double x1, double y1, double z1, double x2, double y2, double z2) => new()
{
MinX = Math.Min(x1, x2), MinY = Math.Min(y1, y2), MinZ = Math.Min(z1, z2),
MaxX = Math.Max(x1, x2), MaxY = Math.Max(y1, y2), MaxZ = Math.Max(z1, z2),
};
private static string SafeStr(Func<string?> g) { try { return g() ?? ""; } catch { return ""; } }
private static double SafeNum(Func<double> g) { try { return g(); } catch { return 0; } }
private static bool SafeBool(Func<bool> g) { try { return g(); } catch { return false; } }
private static string Inv(FormattableString fs) => FormattableString.Invariant(fs);
private static void Release(object? com)
{
if (com is not null && Marshal.IsComObject(com))
{
try { Marshal.ReleaseComObject(com); } catch { }
}
}
}
+46 -72
View File
@@ -1,5 +1,6 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Threading;
using Kompas6API5;
using Kompas6Constants3D;
@@ -14,6 +15,9 @@ public sealed class QueryService
// Иначе КОМПАС вернёт значения в единицах по умолчанию (см/г).
private const int MixMmKg = 0x1 | 0x10;
// ST_MIX_MM (0x1) — площади граней / длины рёбер в мм.
private const uint MixMm = 0x1;
private readonly KompasSession _session;
private readonly KompasDispatcher _dispatcher;
@@ -33,11 +37,7 @@ public sealed class QueryService
public Task<PartInfo> GetPartInfoAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var doc3d = _session.Kompas.ActiveDocument3D() as ksDocument3D
?? throw new InvalidOperationException("Нет активного 3D-документа.");
var part = doc3d.GetPart((short)Part_Type.pTop_Part) as ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
var part = GetTopPart();
ksMassInertiaParam? mass = null;
try
{
@@ -56,8 +56,8 @@ public sealed class QueryService
}
finally
{
if (mass is not null && Marshal.IsComObject(mass)) Marshal.ReleaseComObject(mass);
if (Marshal.IsComObject(part)) Marshal.ReleaseComObject(part);
ComHelper.Release(mass);
ComHelper.Release(part);
}
}, ct);
@@ -83,13 +83,22 @@ public sealed class QueryService
bool gab = false;
try { gab = p.GetGabarit(false, false, out x1, out y1, out z1, out x2, out y2, out z2); }
catch { /* габарит может быть недоступен у отдельных компонентов */ }
// Inline safe-геттеры: избегаем лямбда-аллокаций в горячем цикле по компонентам.
string name = "", marking = "", fileName = "";
bool isDetail = false;
try { name = p.Name ?? ""; } catch { }
try { marking = p.Marking ?? ""; } catch { }
try { fileName = p.FileName ?? ""; } catch { }
try { isDetail = p.Detail; } catch { }
list.Add(new ComponentInfo
{
Index = i,
Name = SafeGet(() => p.Name),
Marking = SafeGet(() => p.Marking),
FileName = SafeGet(() => p.FileName),
IsDetail = SafeBool(() => p.Detail),
Name = name,
Marking = marking,
FileName = fileName,
IsDetail = isDetail,
SizeX = gab ? Math.Abs(x2 - x1) : 0,
SizeY = gab ? Math.Abs(y2 - y1) : 0,
SizeZ = gab ? Math.Abs(z2 - z1) : 0,
@@ -98,27 +107,11 @@ public sealed class QueryService
return list;
}, ct);
private static string SafeGet(Func<string?> get)
{
try { return get() ?? string.Empty; } catch { return string.Empty; }
}
private static bool SafeBool(Func<bool> get)
{
try { return get(); } catch { return false; }
}
// ST_MIX_MM (0x1) — площади граней в мм².
private const uint MixMm = 0x1;
/// <summary>Перечислить грани активной детали: индекс, тип поверхности и площадь (мм²).</summary>
public Task<IReadOnlyList<FaceInfo>> ListFacesAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync<IReadOnlyList<FaceInfo>>(() =>
{
var doc3d = _session.Kompas.ActiveDocument3D() as ksDocument3D
?? throw new InvalidOperationException("Нет активного 3D-документа.");
var part = doc3d.GetPart((short)Part_Type.pTop_Part) as ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
var part = GetTopPart();
try
{
var faces = part.EntityCollection((short)Obj3dType.o3d_face) as ksEntityCollection
@@ -133,37 +126,20 @@ public sealed class QueryService
list.Add(new FaceInfo
{
Index = i,
Type = FaceType(def),
Type = GeomClassifiers.FaceType(def),
Area = def.GetArea(MixMm),
});
}
return list;
}
finally
{
if (Marshal.IsComObject(part)) Marshal.ReleaseComObject(part);
}
finally { ComHelper.Release(part); }
}, ct);
private static string FaceType(ksFaceDefinition def)
{
if (def.IsPlanar()) return "plane";
if (def.IsCylinder()) return "cylinder";
if (def.IsCone()) return "cone";
if (def.IsSphere()) return "sphere";
if (def.IsTorus()) return "torus";
if (def.IsNurbsSurface()) return "nurbs";
return "other";
}
/// <summary>Перечислить рёбра активной детали: индекс, тип кривой и длину (мм).</summary>
public Task<IReadOnlyList<EdgeInfo>> ListEdgesAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync<IReadOnlyList<EdgeInfo>>(() =>
{
var doc3d = _session.Kompas.ActiveDocument3D() as ksDocument3D
?? throw new InvalidOperationException("Нет активного 3D-документа.");
var part = doc3d.GetPart((short)Part_Type.pTop_Part) as ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
var part = GetTopPart();
try
{
var edges = part.EntityCollection((short)Obj3dType.o3d_edge) as ksEntityCollection
@@ -178,37 +154,20 @@ public sealed class QueryService
list.Add(new EdgeInfo
{
Index = i,
Type = EdgeType(def),
Type = GeomClassifiers.EdgeType(def),
Length = def.GetLength(MixMm),
});
}
return list;
}
finally
{
if (Marshal.IsComObject(part)) Marshal.ReleaseComObject(part);
}
finally { ComHelper.Release(part); }
}, ct);
private static string EdgeType(ksEdgeDefinition def)
{
if (def.IsLineSeg()) return "line";
if (def.IsCircle()) return "circle";
if (def.IsArc()) return "arc";
if (def.IsEllipseArc()) return "ellipse_arc";
if (def.IsEllipse()) return "ellipse";
if (def.IsNurbs()) return "nurbs";
return "other";
}
/// <summary>Габаритный параллелепипед активной детали (по телам), мм.</summary>
public Task<BoundingBox> GetBoundingBoxAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var doc3d = _session.Kompas.ActiveDocument3D() as ksDocument3D
?? throw new InvalidOperationException("Нет активного 3D-документа.");
var part = doc3d.GetPart((short)Part_Type.pTop_Part) as ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
var part = GetTopPart();
try
{
// full=false → только тела (без вспомогательной геометрии); customizable игнорируется.
@@ -223,9 +182,24 @@ public sealed class QueryService
MaxX = Math.Max(x1, x2), MaxY = Math.Max(y1, y2), MaxZ = Math.Max(z1, z2),
};
}
finally
{
if (Marshal.IsComObject(part)) Marshal.ReleaseComObject(part);
}
finally { ComHelper.Release(part); }
}, ct);
// ── низкоуровневые помощники ──────────────────────────────────────────
/// <summary>
/// Получить вершинный компонент активной детали. Освобождает RCW doc3d сразу после
/// GetPart — ksPart имеет собственный COM-счётчик ссылок.
/// </summary>
private ksPart GetTopPart()
{
var doc3d = _session.Kompas.ActiveDocument3D() as ksDocument3D
?? throw new InvalidOperationException("Нет активного 3D-документа.");
try
{
return doc3d.GetPart((short)Part_Type.pTop_Part) as ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
}
finally { ComHelper.Release(doc3d); }
}
}