feat(inspection): структурное «зрение» модели — осмотр без снимка
Новый слой «модель как данные» (приоритетный над model_snapshot): - describe_model — структурный «паспорт» детали одним вызовом (габарит, МЦХ, тела, сводка топологии, дерево построения с параметрами, переменные) - list_features / list_bodies / list_variables - describe_face / describe_edge (drill-down по индексу) - measure (расстояние/угол между гранями/рёбрами/вершинами) - model_snapshot понижен до fallback для визуально-пространственных вопросов Реализация: ModelInspectionService (COM) + чистый InspectionText (рендер, группировка, детект «импорт без истории») + record-модели; InspectionTools. Дерево читается через ksPart.GetFeature()->SubFeatureCollection; точный тип и параметры операций берутся из GetObject()->GetDefinition() (ksFeature.type отдаёт лишь coarse o3d_entity). Снимок дерева также включает слои conversion (STEP), editing (move_face/solid) и validation, развивавшиеся параллельно. Полный прогон: 61 тест зелёный, 43 MCP-инструмента. Документация (README, ARCHITECTURE, OPEN_QUESTIONS, CLAUDE.md, presentation.html) синхронизирована. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,33 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<AssemblyName>kompas-connect-spike</AssemblyName>
|
||||
<RootNamespace>Kompas.Mcp.ConnectSpike</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Вендорские interop-сборки КОМПАС (из SDK Samples/Common). -->
|
||||
<ItemGroup>
|
||||
<Reference Include="KompasAPI7">
|
||||
<HintPath>$(KompasInteropDir)\KompasAPI7.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
<Reference Include="Kompas6API5">
|
||||
<HintPath>$(KompasInteropDir)\Kompas6API5.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
<Reference Include="Kompas6Constants">
|
||||
<HintPath>$(KompasInteropDir)\Kompas6Constants.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
<Reference Include="Kompas6Constants3D">
|
||||
<HintPath>$(KompasInteropDir)\Kompas6Constants3D.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,137 +0,0 @@
|
||||
// Спайк S1+S2: проверка interop-сборок и подключения к КОМПАС-3D.
|
||||
//
|
||||
// Доказывает, что:
|
||||
// * вендорские interop-сборки (KompasAPI7 / Kompas6API5) собираются и загружаются под net8.0-windows;
|
||||
// * клиент умеет attach к запущенному КОМПАС и launch нового экземпляра;
|
||||
// * получает IApplication (API7), делает окно видимым и читает версию.
|
||||
//
|
||||
// Внимание: Marshal.GetActiveObject отсутствует в .NET 5+, поэтому attach делается
|
||||
// через нативные CLSIDFromProgID + GetActiveObject (oleaut32).
|
||||
//
|
||||
// Логи -> stderr, итог -> stdout. Код возврата 0 = успех.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Kompas6API5;
|
||||
using KompasAPI7;
|
||||
|
||||
namespace Kompas.Mcp.ConnectSpike;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class Program
|
||||
{
|
||||
// Порядок попыток подключения. На v24 Home проверено: есть .7 и .5, нет LT.
|
||||
private static readonly string[] ProgIds =
|
||||
{
|
||||
"KOMPAS.Application.7", // прямой API7
|
||||
"KOMPAS.Application.5", // API5 -> ksGetApplication7()
|
||||
"KOMPASLT.Application.5", // запасной (LT-инсталляции)
|
||||
};
|
||||
|
||||
private static int Main()
|
||||
{
|
||||
Log("=== KOMPAS connect spike ===");
|
||||
bool launchedByUs = false;
|
||||
IApplication? app = null;
|
||||
|
||||
// 1) attach к уже запущенному экземпляру
|
||||
foreach (var id in ProgIds)
|
||||
{
|
||||
var root = AttachByProgId(id);
|
||||
if (root is null) continue;
|
||||
app = AsApplication(root);
|
||||
if (app != null) { Log($"attached: {id}"); break; }
|
||||
}
|
||||
|
||||
// 2) запустить новый экземпляр
|
||||
if (app is null)
|
||||
{
|
||||
foreach (var id in ProgIds)
|
||||
{
|
||||
var root = CreateByProgId(id);
|
||||
if (root is null) continue;
|
||||
app = AsApplication(root);
|
||||
if (app != null) { launchedByUs = true; Log($"launched: {id}"); break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (app is null)
|
||||
{
|
||||
Log("РЕЗУЛЬТАТ: подключиться к КОМПАС не удалось.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
app.Visible = true;
|
||||
Console.WriteLine("CONNECTED");
|
||||
ReportVersion(app);
|
||||
|
||||
Log(launchedByUs
|
||||
? "Экземпляр запущен этим процессом (в реальном сервере здесь решаем про Quit при выходе)."
|
||||
: "Подключились к уже запущенному экземпляру (Quit не вызываем).");
|
||||
Log("Готово. Окно КОМПАС должно быть видимым.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Привести COM-корень к IApplication: API7 — прямой каст, API5 — через ksGetApplication7().</summary>
|
||||
private static IApplication? AsApplication(object root)
|
||||
{
|
||||
if (root is IApplication direct)
|
||||
return direct;
|
||||
|
||||
if (root is KompasObject ko)
|
||||
{
|
||||
try { return ko.ksGetApplication7() as IApplication; }
|
||||
catch (Exception ex) { Log($"ksGetApplication7 failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
try { return (IApplication)root; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static void ReportVersion(IApplication app)
|
||||
{
|
||||
try { Console.WriteLine($"BuildNumber: {app.BuildNumber}"); }
|
||||
catch (Exception ex) { Log($"BuildNumber failed: {ex.Message}"); }
|
||||
|
||||
try
|
||||
{
|
||||
int major = 0, minor = 0, release = 0, build = 0;
|
||||
app.GetSystemVersion(out major, out minor, out release, out build);
|
||||
Console.WriteLine($"Version: {major}.{minor}.{release} (build {build})");
|
||||
}
|
||||
catch (Exception ex) { Log($"GetSystemVersion failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static object? AttachByProgId(string progId)
|
||||
{
|
||||
try
|
||||
{
|
||||
CLSIDFromProgID(progId, out Guid clsid);
|
||||
GetActiveObject(ref clsid, IntPtr.Zero, out object obj);
|
||||
return obj;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null; // не запущен / не зарегистрирован
|
||||
}
|
||||
}
|
||||
|
||||
private static object? CreateByProgId(string progId)
|
||||
{
|
||||
var type = Type.GetTypeFromProgID(progId, throwOnError: false);
|
||||
if (type is null) return null;
|
||||
try { return Activator.CreateInstance(type); }
|
||||
catch (Exception ex) { Log($"CreateInstance({progId}) failed: {ex.Message}"); return null; }
|
||||
}
|
||||
|
||||
[DllImport("ole32.dll", PreserveSig = false)]
|
||||
private static extern void CLSIDFromProgID(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string progId, out Guid clsid);
|
||||
|
||||
[DllImport("oleaut32.dll", PreserveSig = false)]
|
||||
private static extern void GetActiveObject(
|
||||
ref Guid rclsid, IntPtr pvReserved,
|
||||
[MarshalAs(UnmanagedType.IUnknown)] out object ppunk);
|
||||
|
||||
private static void Log(string msg) => Console.Error.WriteLine(msg);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
using Kompas.Mcp.Core.Threading;
|
||||
using Kompas6Constants;
|
||||
using KompasAPI7;
|
||||
|
||||
namespace Kompas.Mcp.Core.Conversion;
|
||||
|
||||
/// <summary>
|
||||
/// Импорт/экспорт обменных форматов (STEP) через КОМПАС-конвертер.
|
||||
/// <para>
|
||||
/// Встроенный конвертер получается через <c>IApplication.get_Converter(код_формата)</c>
|
||||
/// (целочисленный код <c>ksKOMPASConverterEnum</c>, НЕ путь к DLL), параметры — через
|
||||
/// <c>IConverter.ConverterParameters</c>, сама конвертация — через
|
||||
/// <c>IKompasDocument3D1.ConvertTo/FromAdditionFormat</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ConversionService
|
||||
{
|
||||
private readonly KompasSession _session;
|
||||
private readonly KompasDispatcher _dispatcher;
|
||||
|
||||
public ConversionService(KompasSession session, KompasDispatcher dispatcher)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Импортировать STEP-файл в новый документ заданного типа (по умолчанию — сборка) и сделать его активным.
|
||||
/// <paramref name="createComponentFiles"/> — писать ли компоненты сборки отдельными .m3d рядом с STEP.
|
||||
/// </summary>
|
||||
public Task ImportStepAsync(
|
||||
string path,
|
||||
KompasDocumentType docType = KompasDocumentType.Assembly,
|
||||
bool createComponentFiles = false,
|
||||
CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() => ImportCore(path, docType, createComponentFiles), ct);
|
||||
|
||||
private bool ImportCore(string path, KompasDocumentType docType, bool createComponentFiles)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException($"STEP-файл не найден: {path}", path);
|
||||
|
||||
var app = _session.Application;
|
||||
var typeEnum = DocumentTypes.ToEnum(docType);
|
||||
IConverter? conv = null;
|
||||
IAdditionConvertParameters? prm = null;
|
||||
object? doc = null;
|
||||
try
|
||||
{
|
||||
conv = GetConverter(app, StepFormats.ImportConverterCode); // -3
|
||||
conv.GetFilter((int)typeEnum, false /* импорт */, out int cmd);
|
||||
|
||||
doc = app.Documents.Add(typeEnum, true);
|
||||
if (doc is not IKompasDocument3D1 doc3d1)
|
||||
throw new InvalidOperationException("Созданный документ не является 3D-документом (нет IKompasDocument3D1).");
|
||||
|
||||
prm = conv.ConverterParameters(cmd) as IAdditionConvertParameters
|
||||
?? throw new InvalidOperationException("ConverterParameters вернул не IAdditionConvertParameters.");
|
||||
prm.Format = (ksKOMPASConverterEnum)StepFormats.ImportConverterCode;
|
||||
if (createComponentFiles)
|
||||
{
|
||||
try { prm.NeedCreateComponentsFiles = true; }
|
||||
catch { /* свойство может быть недоступно — не критично */ }
|
||||
}
|
||||
|
||||
if (!doc3d1.ConvertFromAdditionFormat(path, (AdditionConvertParameters)prm))
|
||||
throw new InvalidOperationException("ConvertFromAdditionFormat вернул FALSE (импорт STEP не удался).");
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseCom(prm);
|
||||
ReleaseCom(doc);
|
||||
ReleaseCom(conv);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Экспортировать активный 3D-документ в STEP по указанному пути.</summary>
|
||||
public Task ExportStepAsync(string path, StepFormat format = StepFormat.Auto, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() => ExportCore(path, format), ct);
|
||||
|
||||
private bool ExportCore(string path, StepFormat format)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("Путь экспорта не задан.", nameof(path));
|
||||
|
||||
var app = _session.Application;
|
||||
object? active = null;
|
||||
IConverter? conv = null;
|
||||
IAdditionConvertParameters? prm = null;
|
||||
try
|
||||
{
|
||||
active = app.ActiveDocument
|
||||
?? throw new InvalidOperationException("Нет активного документа для экспорта.");
|
||||
if (active is not IKompasDocument3D1 doc3d1)
|
||||
throw new InvalidOperationException("Активный документ не является 3D-документом (нет IKompasDocument3D1).");
|
||||
|
||||
int formatCode = StepFormats.ExportConverterCode(format);
|
||||
conv = GetConverter(app, formatCode);
|
||||
conv.GetFilter((int)DocumentTypeEnum.ksDocumentPart, true /* saveAs = экспорт */, out int cmd);
|
||||
|
||||
prm = conv.ConverterParameters(cmd) as IAdditionConvertParameters
|
||||
?? throw new InvalidOperationException("ConverterParameters вернул не IAdditionConvertParameters.");
|
||||
prm.Format = (ksKOMPASConverterEnum)formatCode;
|
||||
|
||||
if (!doc3d1.ConvertToAdditionFormat(path, (AdditionConvertParameters)prm))
|
||||
throw new InvalidOperationException("ConvertToAdditionFormat вернул FALSE (экспорт STEP не удался).");
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseCom(prm);
|
||||
ReleaseCom(active);
|
||||
ReleaseCom(conv);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получить встроенный конвертер. Для STEP get_Converter принимает целочисленный код формата
|
||||
/// (boxed int), НЕ путь к DLL. Пробуем код формата, затем базовый код экспорта STEP (3).
|
||||
/// </summary>
|
||||
private static IConverter GetConverter(IApplication app, int formatCode)
|
||||
{
|
||||
if (app.Converter[(object)formatCode] is IConverter c) return c;
|
||||
// AP-вариант мог не быть отдельным конвертером — берём базовый STEP-экспорт (ksConverterToSTEP = 3).
|
||||
if (app.Converter[(object)3] is IConverter cBase) return cBase;
|
||||
throw new InvalidOperationException(
|
||||
$"get_Converter({formatCode}) вернул null — STEP-конвертер недоступен в этой редакции КОМПАС.");
|
||||
}
|
||||
|
||||
private static void ReleaseCom(object? comObject)
|
||||
{
|
||||
if (comObject is not null && Marshal.IsComObject(comObject))
|
||||
{
|
||||
try { Marshal.ReleaseComObject(comObject); }
|
||||
catch { /* освобождение не критично */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace Kompas.Mcp.Core.Conversion;
|
||||
|
||||
/// <summary>Вариант стандарта STEP для экспорта.</summary>
|
||||
public enum StepFormat
|
||||
{
|
||||
/// <summary>Формат по умолчанию (ksConverterToSTEP).</summary>
|
||||
Auto,
|
||||
Ap203,
|
||||
Ap214,
|
||||
Ap242,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Маппинг формата STEP в целочисленные коды конвертера КОМПАС (<c>ksKOMPASConverterEnum</c>).
|
||||
/// Эти коды передаются в <c>IApplication.get_Converter</c> и <c>IAdditionConvertParameters.Format</c>.
|
||||
/// </summary>
|
||||
public static class StepFormats
|
||||
{
|
||||
/// <summary>Код импорта STEP — <c>ksConverterFromSTEP</c>.</summary>
|
||||
public const int ImportConverterCode = -3;
|
||||
|
||||
/// <summary>Код экспорта STEP для выбранного стандарта (<c>ksConverterToSTEP[_APxxx]</c>).</summary>
|
||||
public static int ExportConverterCode(StepFormat format) => format switch
|
||||
{
|
||||
StepFormat.Auto => 3,
|
||||
StepFormat.Ap203 => 203,
|
||||
StepFormat.Ap214 => 214,
|
||||
StepFormat.Ap242 => 242,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Неизвестный формат STEP."),
|
||||
};
|
||||
|
||||
/// <summary>Разобрать строку формата ("auto"/"step", "ap203", "ap214", "ap242").</summary>
|
||||
public static StepFormat Parse(string? value) => value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
null or "" or "auto" or "step" => StepFormat.Auto,
|
||||
"ap203" or "203" => StepFormat.Ap203,
|
||||
"ap214" or "214" => StepFormat.Ap214,
|
||||
"ap242" or "242" => StepFormat.Ap242,
|
||||
_ => throw new ArgumentException(
|
||||
$"Неизвестный формат STEP '{value}'. Допустимо: auto, ap203, ap214, ap242.", nameof(value)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Kompas.Mcp.Core.Editing;
|
||||
|
||||
/// <summary>Габарит тела (по индексу в наборе тел детали) — для выбора тела в операциях редактирования.</summary>
|
||||
public sealed record BodyBox
|
||||
{
|
||||
public required int Index { get; init; }
|
||||
public required double MinX { get; init; }
|
||||
public required double MinY { get; init; }
|
||||
public required double MinZ { get; init; }
|
||||
public required double MaxX { get; init; }
|
||||
public required double MaxY { get; init; }
|
||||
public required double MaxZ { get; init; }
|
||||
|
||||
public double SizeX => Math.Abs(MaxX - MinX);
|
||||
public double SizeY => Math.Abs(MaxY - MinY);
|
||||
public double SizeZ => Math.Abs(MaxZ - MinZ);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Kompas.Mcp.Core.Threading;
|
||||
using KompasAPI7;
|
||||
|
||||
namespace Kompas.Mcp.Core.Editing;
|
||||
|
||||
/// <summary>
|
||||
/// Прямое редактирование тела: перемещение грани (КОМПАС «Переместить грань», <c>FaceMover</c>).
|
||||
/// Работает и на импортированной «тупой» B-rep геометрии без дерева построения.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class FaceEditService
|
||||
{
|
||||
private readonly KompasSession _session;
|
||||
private readonly KompasDispatcher _dispatcher;
|
||||
|
||||
public FaceEditService(KompasSession session, KompasDispatcher dispatcher)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Переместить грань активной детали, найденную по мировой точке (<paramref name="x"/>,<paramref name="y"/>,<paramref name="z"/>),
|
||||
/// на <paramref name="distance"/> мм вдоль её нормали. Направление подбирается автоматически
|
||||
/// (наружу — добавление материала); если КОМПАС отвергает направление, пробуется обратное.
|
||||
/// </summary>
|
||||
public Task MoveFaceAsync(double x, double y, double z, double distance, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() => MoveFaceCore(x, y, z, distance), ct);
|
||||
|
||||
private bool MoveFaceCore(double x, double y, double z, double distance)
|
||||
{
|
||||
if (distance == 0) throw new ArgumentException("Смещение не может быть нулевым.", nameof(distance));
|
||||
|
||||
if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d)
|
||||
throw new InvalidOperationException("Активный документ не является 3D-документом.");
|
||||
if (doc3d.TopPart is not IPart7 top)
|
||||
throw new InvalidOperationException("Не удалось получить TopPart активного документа.");
|
||||
|
||||
var face = FindFaceAtPoint(top, x, y, z)
|
||||
?? throw new InvalidOperationException(
|
||||
$"В точке ({x}, {y}, {z}) не найдена грань. Сверьте координаты точки на грани (model_snapshot).");
|
||||
|
||||
var sc = top as ISurfaceContainer
|
||||
?? throw new InvalidOperationException("Деталь не приводится к ISurfaceContainer (FaceMover недоступен).");
|
||||
|
||||
var fm = sc.FaceMovers.Add()
|
||||
?? throw new InvalidOperationException("FaceMovers.Add() вернул null.");
|
||||
if (!fm.SetFaces(new object[] { face }, true))
|
||||
throw new InvalidOperationException("FaceMover.SetFaces отверг грань.");
|
||||
fm.Offset = Math.Abs(distance);
|
||||
|
||||
// Направление: пробуем одно, при отказе — обратное (нормаль грани может смотреть в любую сторону).
|
||||
fm.Direction = distance > 0;
|
||||
if (!fm.Update())
|
||||
{
|
||||
fm.Direction = !fm.Direction;
|
||||
if (!fm.Update())
|
||||
throw new InvalidOperationException("FaceMover.Update вернул FALSE для обоих направлений.");
|
||||
}
|
||||
|
||||
// Перестраиваем, чтобы состояние ошибки операции стало актуальным (проверяется через validate_part).
|
||||
doc3d.RebuildDocument();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IFace? FindFaceAtPoint(IPart7 part, double x, double y, double z)
|
||||
{
|
||||
var hit = part.FindObjectsByPoint(x, y, z, true);
|
||||
switch (hit)
|
||||
{
|
||||
case IFace f:
|
||||
return f;
|
||||
case object[] arr:
|
||||
foreach (var o in arr) if (o is IFace face) return face;
|
||||
return null;
|
||||
case System.Collections.IEnumerable en:
|
||||
foreach (var o in en) if (o is IFace face) return face;
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
using Kompas.Mcp.Core.Threading;
|
||||
using Kompas6Constants;
|
||||
using Kompas6Constants3D;
|
||||
using KompasAPI7;
|
||||
|
||||
namespace Kompas.Mcp.Core.Editing;
|
||||
|
||||
/// <summary>
|
||||
/// Операции над телами импортированной/построенной геометрии: рассечение плоскостью
|
||||
/// (<c>SplitSolids</c>), перемещение тела (<c>BodyRepositions</c>), объединение (<c>Booleans</c>).
|
||||
/// Контейнеры операций берутся приведением детали (COM-QI): <c>IModelContainer</c>/<c>IAuxiliaryGeomContainer</c>.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class SolidEditService
|
||||
{
|
||||
private readonly KompasSession _session;
|
||||
private readonly KompasDispatcher _dispatcher;
|
||||
|
||||
public SolidEditService(KompasSession session, KompasDispatcher dispatcher)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Рассечь тело активной детали смещённой плоскостью (база <paramref name="basePlane"/> + <paramref name="offset"/> мм).
|
||||
/// Возвращает габариты получившихся тел (с индексами) — по ним выбирают тело для <see cref="MoveBodyAsync"/>.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<BodyBox>> SplitByPlaneAsync(
|
||||
BasePlane basePlane, double offset, bool direction = true, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync<IReadOnlyList<BodyBox>>(() =>
|
||||
{
|
||||
var (doc3d, top) = ActivePart();
|
||||
var ag = top as IAuxiliaryGeomContainer
|
||||
?? throw new InvalidOperationException("Деталь не приводится к IAuxiliaryGeomContainer.");
|
||||
var plane = (IPlane3DByOffset)ag.Planes3D.Add(ksObj3dTypeEnum.o3d_planeOffset);
|
||||
plane.BasePlane = top.DefaultObject[PlaneType(basePlane)];
|
||||
plane.Offset = offset;
|
||||
plane.Direction = direction;
|
||||
if (!plane.Update())
|
||||
throw new InvalidOperationException("Не удалось построить секущую плоскость.");
|
||||
|
||||
var mc = ModelContainer(top);
|
||||
var ss = mc.SplitSolids.Add();
|
||||
ss.CutObjects = plane;
|
||||
if (!ss.Update())
|
||||
throw new InvalidOperationException("SplitSolid.Update вернул FALSE (рассечение не удалось).");
|
||||
doc3d.RebuildDocument();
|
||||
return BodyBoxes(top);
|
||||
}, ct);
|
||||
|
||||
/// <summary>Переместить тело (по индексу из <see cref="SplitByPlaneAsync"/>) на вектор (dx, dy, dz) мм.</summary>
|
||||
public Task MoveBodyAsync(int bodyIndex, double dx = 0, double dy = 0, double dz = 0, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var (doc3d, top) = ActivePart();
|
||||
var bodies = ResultBodies(top);
|
||||
if (bodyIndex < 0 || bodyIndex >= bodies.Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(bodyIndex),
|
||||
$"Индекс тела вне диапазона [0; {bodies.Count - 1}].");
|
||||
|
||||
var mc = ModelContainer(top);
|
||||
var br = mc.BodyRepositions.Add();
|
||||
br.RepositionBody = (IKompasAPIObject)bodies[bodyIndex];
|
||||
var pos = br.Position;
|
||||
pos.SetStartingOrientation();
|
||||
pos.X = dx; pos.Y = dy; pos.Z = dz; // смещение начала ЛСК тела = перенос тела
|
||||
if (!br.Update())
|
||||
throw new InvalidOperationException("BodyReposition.Update вернул FALSE.");
|
||||
doc3d.RebuildDocument();
|
||||
}, ct);
|
||||
|
||||
/// <summary>Объединить все тела активной детали в одно (булева операция «объединение»).</summary>
|
||||
public Task UnionAllAsync(CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var (doc3d, top) = ActivePart();
|
||||
var bodies = ResultBodies(top);
|
||||
if (bodies.Count < 2) return; // объединять нечего
|
||||
|
||||
var mc = ModelContainer(top);
|
||||
var bln = mc.Booleans.Add();
|
||||
bln.BaseObject = (IKompasAPIObject)bodies[0];
|
||||
bln.ModifyObjects = bodies.Skip(1).Cast<object>().ToArray();
|
||||
bln.BooleanType = ksBooleanType.ksUnion;
|
||||
if (!bln.Update())
|
||||
throw new InvalidOperationException("Boolean union.Update вернул FALSE.");
|
||||
doc3d.RebuildDocument();
|
||||
}, ct);
|
||||
|
||||
// ---- внутреннее --------------------------------------------------------
|
||||
|
||||
private (IKompasDocument3D doc, IPart7 top) ActivePart()
|
||||
{
|
||||
if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d)
|
||||
throw new InvalidOperationException("Активный документ не является 3D-документом.");
|
||||
if (doc3d.TopPart is not IPart7 top)
|
||||
throw new InvalidOperationException("Не удалось получить TopPart активного документа.");
|
||||
return (doc3d, top);
|
||||
}
|
||||
|
||||
private static IModelContainer ModelContainer(IPart7 top)
|
||||
=> top as IModelContainer
|
||||
?? throw new InvalidOperationException("Деталь не приводится к IModelContainer.");
|
||||
|
||||
private static ksObj3dTypeEnum PlaneType(BasePlane p) => p switch
|
||||
{
|
||||
BasePlane.XOY => ksObj3dTypeEnum.o3d_planeXOY,
|
||||
BasePlane.XOZ => ksObj3dTypeEnum.o3d_planeXOZ,
|
||||
BasePlane.YOZ => ksObj3dTypeEnum.o3d_planeYOZ,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(p), p, "Неизвестная базовая плоскость."),
|
||||
};
|
||||
|
||||
private static List<IBody7> ResultBodies(IPart7 top)
|
||||
{
|
||||
var list = new List<IBody7>();
|
||||
var rb = ((IFeature7)top).ResultBodies;
|
||||
if (rb is object[] arr) foreach (var o in arr) { if (o is IBody7 b) list.Add(b); }
|
||||
else if (rb is System.Collections.IEnumerable en) foreach (var o in en) { if (o is IBody7 b) list.Add(b); }
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<BodyBox> BodyBoxes(IPart7 top)
|
||||
{
|
||||
var bodies = ResultBodies(top);
|
||||
var list = new List<BodyBox>(bodies.Count);
|
||||
for (int i = 0; i < bodies.Count; i++)
|
||||
{
|
||||
double x1 = 0, y1 = 0, z1 = 0, x2 = 0, y2 = 0, z2 = 0;
|
||||
try { bodies[i].GetGabarit(out x1, out y1, out z1, out x2, out y2, out z2); } catch { /* ignore */ }
|
||||
list.Add(new BodyBox { Index = i, MinX = x1, MinY = y1, MinZ = z1, MaxX = x2, MaxY = y2, MaxZ = z2 });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Kompas.Mcp.Core.Query;
|
||||
|
||||
/// <summary>Сводка по телу детали.</summary>
|
||||
public sealed record BodyInfo
|
||||
{
|
||||
/// <summary>Индекс тела в коллекции тел детали.</summary>
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>true — твёрдое (замкнутое) тело.</summary>
|
||||
public required bool IsSolid { get; init; }
|
||||
|
||||
/// <summary>Число граней тела.</summary>
|
||||
public required int FaceCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Kompas.Mcp.Core.Query;
|
||||
|
||||
/// <summary>Сводка по компоненту сборки (деталь или подсборка верхнего уровня).</summary>
|
||||
public sealed record ComponentInfo
|
||||
{
|
||||
/// <summary>Индекс в коллекции компонентов верхнего уровня.</summary>
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>Имя компонента в дереве.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Обозначение (marking), если задано.</summary>
|
||||
public required string Marking { get; init; }
|
||||
|
||||
/// <summary>Путь к файлу-источнику компонента, если есть.</summary>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>true — деталь, false — подсборка.</summary>
|
||||
public required bool IsDetail { get; init; }
|
||||
|
||||
/// <summary>Габаритные размеры компонента (мм). 0, если габарит недоступен.</summary>
|
||||
public required double SizeX { get; init; }
|
||||
public required double SizeY { get; init; }
|
||||
public required double SizeZ { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Kompas.Mcp.Core.Query;
|
||||
|
||||
/// <summary>Детальные сведения о ребре (drill-down по индексу из list_edges).</summary>
|
||||
public sealed record EdgeDetail
|
||||
{
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>Тип кривой: line, circle, arc, ellipse, ellipse_arc, nurbs, other.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Длина ребра, мм.</summary>
|
||||
public required double Length { get; init; }
|
||||
|
||||
/// <summary>Тип одной смежной грани (по «плюс»-ориентации), либо null.</summary>
|
||||
public string? AdjacentFace1 { get; init; }
|
||||
|
||||
/// <summary>Тип другой смежной грани (по «минус»-ориентации), либо null.</summary>
|
||||
public string? AdjacentFace2 { get; init; }
|
||||
|
||||
/// <summary>Координаты начальной вершины (мм), либо null.</summary>
|
||||
public (double X, double Y, double Z)? Start { get; init; }
|
||||
|
||||
/// <summary>Координаты конечной вершины (мм), либо null.</summary>
|
||||
public (double X, double Y, double Z)? End { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Kompas.Mcp.Core.Query;
|
||||
|
||||
/// <summary>Детальные сведения о грани (drill-down по индексу из list_faces).</summary>
|
||||
public sealed record FaceDetail
|
||||
{
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>Тип поверхности: plane, cylinder, cone, sphere, torus, nurbs, other.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Площадь грани, мм².</summary>
|
||||
public required double Area { get; init; }
|
||||
|
||||
/// <summary>Единичная нормаль в центре грани (мировые оси), либо null если недоступна.</summary>
|
||||
public (double X, double Y, double Z)? Normal { get; init; }
|
||||
|
||||
/// <summary>Радиус цилиндрической грани, мм (null для нецилиндрических).</summary>
|
||||
public double? Radius { get; init; }
|
||||
|
||||
/// <summary>Число рёбер, ограничивающих грань.</summary>
|
||||
public required int EdgeCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Kompas.Mcp.Core.Query;
|
||||
|
||||
/// <summary>Узел дерева построения детали (операция или системный объект).</summary>
|
||||
public sealed record FeatureInfo
|
||||
{
|
||||
/// <summary>Порядковый индекс в дереве построения.</summary>
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>Имя узла в дереве (локализовано КОМПАС: «Элемент выдавливания:1», «Скругление:1»…).</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Семантический вид операции, определённый по типу её определения:
|
||||
/// origin, sketch, extrusion, cut_extrusion, revolve, cut_revolve, fillet, chamfer, body, other.
|
||||
/// </summary>
|
||||
public required string Kind { get; init; }
|
||||
|
||||
/// <summary>Ключевые параметры операции для показа («глубина 15.00 мм», «R3.00 мм»), либо null.</summary>
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Kompas.Mcp.Core.Query;
|
||||
|
||||
/// <summary>
|
||||
/// Чистый (без COM) рендер структурного «паспорта» модели и вспомогательные форматтеры.
|
||||
/// Вынесено отдельно, чтобы покрыть логику представления юнит-тестами.
|
||||
/// </summary>
|
||||
public static class InspectionText
|
||||
{
|
||||
// Габарит пустой детали КОМПАС отдаёт «пустышкой» ~2·10³⁰⁸ (double близко к максимуму).
|
||||
private const double EmptyGabaritThreshold = 1e12;
|
||||
|
||||
/// <summary>true — габарит соответствует пустой модели (sentinel-«пустышка» или нулевой размер).</summary>
|
||||
public static bool IsEmptyGabarit(double sizeX, double sizeY, double sizeZ)
|
||||
{
|
||||
if (double.IsNaN(sizeX) || double.IsNaN(sizeY) || double.IsNaN(sizeZ)) return true;
|
||||
if (Math.Abs(sizeX) > EmptyGabaritThreshold ||
|
||||
Math.Abs(sizeY) > EmptyGabaritThreshold ||
|
||||
Math.Abs(sizeZ) > EmptyGabaritThreshold) return true;
|
||||
return sizeX == 0 && sizeY == 0 && sizeZ == 0;
|
||||
}
|
||||
|
||||
// Формообразующие операции, наличие которых означает параметрическую историю.
|
||||
private static readonly string[] OpKinds =
|
||||
{ "sketch", "extrusion", "cut_extrusion", "revolve", "cut_revolve", "fillet", "chamfer" };
|
||||
|
||||
/// <summary>
|
||||
/// Модель — «голый» импорт без истории построения (типично для STEP): в дереве только начало
|
||||
/// координат и тело(а), нет ни формообразующих операций, ни иных узлов-правок.
|
||||
/// </summary>
|
||||
public static bool IsImported(IReadOnlyList<FeatureInfo> features, int bodyCount)
|
||||
=> bodyCount > 0
|
||||
&& !features.Any(f => OpKinds.Contains(f.Kind))
|
||||
&& features.Count <= bodyCount + 1; // origin + тело(а), ничего сверх
|
||||
|
||||
/// <summary>Сгруппировать типы и вернуть «3 plane, 2 cylinder» (по убыванию числа, тай-брейк по имени).</summary>
|
||||
public static string GroupTypes(IReadOnlyList<string> types)
|
||||
{
|
||||
if (types.Count == 0) return "—";
|
||||
return string.Join(", ", types
|
||||
.GroupBy(t => t)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ThenBy(g => g.Key, StringComparer.Ordinal)
|
||||
.Select(g => $"{g.Count()} {g.Key}"));
|
||||
}
|
||||
|
||||
/// <summary>Отрендерить «паспорт» модели в читаемый текст.</summary>
|
||||
public static string Render(ModelDescription d)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Документ: «{d.Name}» (деталь)");
|
||||
|
||||
if (d.IsEmpty)
|
||||
{
|
||||
sb.Append("Модель пуста: нет тел (габарит не определён).");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
if (d.Box is { } box)
|
||||
sb.AppendLine(Inv($"Габарит (Д×Ш×В): {box.SizeX:F2} × {box.SizeY:F2} × {box.SizeZ:F2} мм"));
|
||||
|
||||
if (d.Mass is { } m)
|
||||
sb.AppendLine(Inv($"МЦХ: объём {m.Volume:F1} мм³, масса {m.Mass:F4} кг, площадь {m.Area:F1} мм², центр масс ({m.CenterX:F2}, {m.CenterY:F2}, {m.CenterZ:F2})"));
|
||||
|
||||
sb.AppendLine($"Тела: {d.Bodies.Count}" + FormatBodies(d.Bodies));
|
||||
|
||||
sb.AppendLine($"Топология: граней {d.FaceTypes.Count} ({GroupTypes(d.FaceTypes)}); " +
|
||||
$"рёбер {d.EdgeTypes.Count} ({GroupTypes(d.EdgeTypes)})");
|
||||
|
||||
AppendTree(sb, d);
|
||||
AppendVariables(sb, d.Variables);
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string FormatBodies(IReadOnlyList<BodyInfo> bodies)
|
||||
{
|
||||
if (bodies.Count == 0) return "";
|
||||
var parts = bodies.Select(b => $"тело #{b.Index}{(b.IsSolid ? " (твёрдое)" : "")}, граней {b.FaceCount}");
|
||||
return " — " + string.Join("; ", parts);
|
||||
}
|
||||
|
||||
private static void AppendTree(StringBuilder sb, ModelDescription d)
|
||||
{
|
||||
if (d.Imported)
|
||||
{
|
||||
var names = string.Join(", ", d.Features.Select(f => $"«{f.Name}»"));
|
||||
sb.AppendLine("Дерево построения: импортированная модель без истории построения " +
|
||||
"(доступна только топология)." + (names.Length > 0 ? " Узлы: " + names + "." : ""));
|
||||
return;
|
||||
}
|
||||
|
||||
if (d.Features.Count == 0)
|
||||
{
|
||||
sb.AppendLine("Дерево построения: нет операций.");
|
||||
return;
|
||||
}
|
||||
|
||||
sb.AppendLine($"Дерево построения ({d.Features.Count} операц.):");
|
||||
foreach (var f in d.Features)
|
||||
sb.AppendLine($" [{f.Index}] «{f.Name}»" + (string.IsNullOrEmpty(f.Detail) ? "" : $" — {f.Detail}"));
|
||||
}
|
||||
|
||||
private static void AppendVariables(StringBuilder sb, IReadOnlyList<VariableInfo> vars)
|
||||
{
|
||||
if (vars.Count == 0) { sb.AppendLine("Переменные: нет"); return; }
|
||||
|
||||
sb.AppendLine($"Переменные ({vars.Count}):");
|
||||
foreach (var v in vars)
|
||||
{
|
||||
var flags = (v.External ? " [внешняя]" : "") + (v.Information ? " [инфо]" : "");
|
||||
sb.AppendLine(Inv($" {v.Name} = {v.Expression} (= {v.Value:G}){flags}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static string Inv(FormattableString fs) => FormattableString.Invariant(fs);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Kompas.Mcp.Core.Query;
|
||||
|
||||
/// <summary>Результат измерения между двумя объектами модели (грань/ребро/вершина).</summary>
|
||||
public sealed record MeasureResult
|
||||
{
|
||||
/// <summary>Расстояние, мм (для совпадающих/пересекающихся объектов — 0).</summary>
|
||||
public required double Distance { get; init; }
|
||||
|
||||
/// <summary>Минимальное расстояние, мм.</summary>
|
||||
public required double MinDistance { get; init; }
|
||||
|
||||
/// <summary>Угол между объектами, градусы (валиден только когда <see cref="AngleValid"/>).</summary>
|
||||
public required double Angle { get; init; }
|
||||
|
||||
/// <summary>true — угол применим к данной паре объектов.</summary>
|
||||
public required bool AngleValid { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Kompas.Mcp.Core.Query;
|
||||
|
||||
/// <summary>
|
||||
/// Структурный «паспорт» активной детали: всё, что нужно агенту, чтобы «увидеть» модель
|
||||
/// без растрового снимка. Заполняется <c>ModelInspectionService</c>, рендерится <c>InspectionText</c>.
|
||||
/// </summary>
|
||||
public sealed record ModelDescription
|
||||
{
|
||||
/// <summary>Имя документа/детали.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Модель пуста (нет тел; габарит-«пустышка»).</summary>
|
||||
public required bool IsEmpty { get; init; }
|
||||
|
||||
/// <summary>Габарит детали (null для пустой модели).</summary>
|
||||
public BoundingBox? Box { get; init; }
|
||||
|
||||
/// <summary>Массово-центровочные характеристики (null для пустой модели).</summary>
|
||||
public PartInfo? Mass { get; init; }
|
||||
|
||||
/// <summary>Тела детали.</summary>
|
||||
public required IReadOnlyList<BodyInfo> Bodies { get; init; }
|
||||
|
||||
/// <summary>Типы всех граней (для сводки по топологии).</summary>
|
||||
public required IReadOnlyList<string> FaceTypes { get; init; }
|
||||
|
||||
/// <summary>Типы всех рёбер (для сводки по топологии).</summary>
|
||||
public required IReadOnlyList<string> EdgeTypes { get; init; }
|
||||
|
||||
/// <summary>Узлы дерева построения по порядку.</summary>
|
||||
public required IReadOnlyList<FeatureInfo> Features { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Модель импортирована (дерево содержит только начало координат и тело(а), без
|
||||
/// параметрической истории) — типично для импорта STEP.
|
||||
/// </summary>
|
||||
public required bool Imported { get; init; }
|
||||
|
||||
/// <summary>Переменные модели.</summary>
|
||||
public required IReadOnlyList<VariableInfo> Variables { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Kompas.Mcp.Core.Threading;
|
||||
using Kompas6API5;
|
||||
using Kompas6Constants3D;
|
||||
|
||||
namespace Kompas.Mcp.Core.Query;
|
||||
|
||||
/// <summary>
|
||||
/// Структурное «зрение» агента: чтение модели как данных, а не картинки — дерево построения,
|
||||
/// тела, топология, переменные, drill-down по граням/рёбрам и измерения. Всё через API5
|
||||
/// <c>ksPart</c> на STA-потоке <see cref="KompasDispatcher"/>.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ModelInspectionService
|
||||
{
|
||||
private const uint MixMm = 0x1; // длины/площади — мм
|
||||
private const int MixMmKg = 0x1 | 0x10; // МЦХ — мм + кг
|
||||
|
||||
private readonly KompasSession _session;
|
||||
private readonly KompasDispatcher _dispatcher;
|
||||
|
||||
public ModelInspectionService(KompasSession session, KompasDispatcher dispatcher)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
}
|
||||
|
||||
/// <summary>Собрать структурный «паспорт» активной детали одним проходом.</summary>
|
||||
public Task<ModelDescription> DescribeModelAsync(CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var part = GetTopPart();
|
||||
try
|
||||
{
|
||||
var name = ActiveDocName();
|
||||
|
||||
bool hasGab = part.GetGabarit(false, false,
|
||||
out double x1, out double y1, out double z1, out double x2, out double y2, out double z2);
|
||||
var box = hasGab ? Box(x1, y1, z1, x2, y2, z2) : null;
|
||||
bool empty = box is null || InspectionText.IsEmptyGabarit(box.SizeX, box.SizeY, box.SizeZ);
|
||||
|
||||
if (empty)
|
||||
return new ModelDescription
|
||||
{
|
||||
Name = name, IsEmpty = true,
|
||||
Bodies = Array.Empty<BodyInfo>(), FaceTypes = Array.Empty<string>(),
|
||||
EdgeTypes = Array.Empty<string>(), Features = Array.Empty<FeatureInfo>(),
|
||||
Imported = false, Variables = Array.Empty<VariableInfo>(),
|
||||
};
|
||||
|
||||
var bodies = ReadBodies(part);
|
||||
var (faceTypes, edgeTypes) = ReadTopology(part);
|
||||
var features = ReadFeatures(part);
|
||||
var variables = ReadVariables(part);
|
||||
|
||||
return new ModelDescription
|
||||
{
|
||||
Name = name,
|
||||
IsEmpty = false,
|
||||
Box = box,
|
||||
Mass = ReadMass(part),
|
||||
Bodies = bodies,
|
||||
FaceTypes = faceTypes,
|
||||
EdgeTypes = edgeTypes,
|
||||
Features = features,
|
||||
Imported = InspectionText.IsImported(features, bodies.Count),
|
||||
Variables = variables,
|
||||
};
|
||||
}
|
||||
finally { Release(part); }
|
||||
}, ct);
|
||||
|
||||
/// <summary>Перечислить узлы дерева построения по порядку.</summary>
|
||||
public Task<IReadOnlyList<FeatureInfo>> ListFeaturesAsync(CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var part = GetTopPart();
|
||||
try { return ReadFeatures(part); }
|
||||
finally { Release(part); }
|
||||
}, ct);
|
||||
|
||||
/// <summary>Перечислить тела детали.</summary>
|
||||
public Task<IReadOnlyList<BodyInfo>> ListBodiesAsync(CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var part = GetTopPart();
|
||||
try { return ReadBodies(part); }
|
||||
finally { Release(part); }
|
||||
}, ct);
|
||||
|
||||
/// <summary>Перечислить переменные модели.</summary>
|
||||
public Task<IReadOnlyList<VariableInfo>> ListVariablesAsync(CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var part = GetTopPart();
|
||||
try { return ReadVariables(part); }
|
||||
finally { Release(part); }
|
||||
}, ct);
|
||||
|
||||
/// <summary>Детали грани по индексу из list_faces: тип, площадь, нормаль, радиус, число рёбер.</summary>
|
||||
public Task<FaceDetail> DescribeFaceAsync(int index, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var part = GetTopPart();
|
||||
try
|
||||
{
|
||||
var faces = Faces(part);
|
||||
var def = FaceAt(faces, index);
|
||||
|
||||
double area = 0; try { area = def.GetArea(MixMm); } catch { }
|
||||
int edgeCount = 0; try { if (def.EdgeCollection() is ksEdgeCollection ec) edgeCount = ec.GetCount(); } catch { }
|
||||
double? radius = null;
|
||||
try { if (def.IsCylinder() && def.GetCylinderParam(out _, out double r)) radius = r; } catch { }
|
||||
|
||||
return new FaceDetail
|
||||
{
|
||||
Index = index,
|
||||
Type = FaceType(def),
|
||||
Area = area,
|
||||
Normal = FaceNormal(def),
|
||||
Radius = radius,
|
||||
EdgeCount = edgeCount,
|
||||
};
|
||||
}
|
||||
finally { Release(part); }
|
||||
}, ct);
|
||||
|
||||
/// <summary>Детали ребра по индексу из list_edges: тип, длина, смежные грани, концевые вершины.</summary>
|
||||
public Task<EdgeDetail> DescribeEdgeAsync(int index, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var part = GetTopPart();
|
||||
try
|
||||
{
|
||||
var edges = Edges(part);
|
||||
var def = EdgeAt(edges, index);
|
||||
|
||||
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 { }
|
||||
|
||||
return new EdgeDetail
|
||||
{
|
||||
Index = index,
|
||||
Type = EdgeType(def),
|
||||
Length = len,
|
||||
AdjacentFace1 = f1,
|
||||
AdjacentFace2 = f2,
|
||||
Start = Vertex(def, true),
|
||||
End = Vertex(def, false),
|
||||
};
|
||||
}
|
||||
finally { Release(part); }
|
||||
}, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Измерить расстояние и угол между двумя объектами модели. <paramref name="kind1"/>/<paramref name="kind2"/>
|
||||
/// — face|edge|vertex; индексы — из list_faces/list_edges.
|
||||
/// </summary>
|
||||
public Task<MeasureResult> MeasureAsync(string kind1, int index1, string kind2, int index2, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var part = GetTopPart();
|
||||
try
|
||||
{
|
||||
var o1 = ResolveObject(part, kind1, index1);
|
||||
var o2 = ResolveObject(part, kind2, index2);
|
||||
|
||||
var meas = part.GetMeasurer() as ksMeasurer
|
||||
?? throw new InvalidOperationException("GetMeasurer вернул null.");
|
||||
meas.SetObject1(o1);
|
||||
meas.SetObject2(o2);
|
||||
meas.unit = MixMm;
|
||||
if (!meas.Calc())
|
||||
throw new InvalidOperationException("Измерение не выполнено (Calc вернул FALSE).");
|
||||
|
||||
bool angleValid = false; try { angleValid = meas.IsAngleValid(); } catch { }
|
||||
return new MeasureResult
|
||||
{
|
||||
Distance = meas.distance,
|
||||
MinDistance = meas.MinDistance,
|
||||
Angle = angleValid ? meas.angle : 0,
|
||||
AngleValid = angleValid,
|
||||
};
|
||||
}
|
||||
finally { Release(part); }
|
||||
}, ct);
|
||||
|
||||
// ── чтение данных (на STA-потоке) ─────────────────────────────────────
|
||||
|
||||
private static IReadOnlyList<FeatureInfo> ReadFeatures(ksPart part)
|
||||
{
|
||||
// Дерево детали — через ksPart.GetFeature() (приведение part as ksFeature не срабатывает).
|
||||
var root = part.GetFeature() as ksFeature ?? part as ksFeature;
|
||||
if (root?.SubFeatureCollection(true, false) is not ksFeatureCollection tree) return Array.Empty<FeatureInfo>();
|
||||
|
||||
int n = tree.GetCount();
|
||||
var list = new List<FeatureInfo>(n);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (tree.GetByIndex(i) is not ksFeature f) continue;
|
||||
string name = ""; try { name = f.name ?? ""; } catch { }
|
||||
var (kind, detail) = ClassifyFeature(f, name);
|
||||
list.Add(new FeatureInfo { Index = i, Name = name, Kind = kind, Detail = detail });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// ksFeature.type отдаёт лишь coarse o3d_entity → точный вид и параметры берём из определения.
|
||||
private static (string kind, string? detail) ClassifyFeature(ksFeature f, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (f.GetObject() is ksEntity ent)
|
||||
switch (ent.GetDefinition())
|
||||
{
|
||||
case ksBossExtrusionDefinition be:
|
||||
return ("extrusion", be.GetSideParam(true, out _, out double d1, out _, out _) ? Inv($"глубина {d1:F2} мм") : null);
|
||||
case ksCutExtrusionDefinition ce:
|
||||
return ("cut_extrusion", ce.GetSideParam(true, out _, out double d2, out _, out _) ? Inv($"вырез, глубина {d2:F2} мм") : "вырез");
|
||||
case ksFilletDefinition fd:
|
||||
return ("fillet", Inv($"R{fd.radius:F2} мм"));
|
||||
case ksChamferDefinition cd:
|
||||
return ("chamfer", cd.GetChamferParam(out _, out double s1, out double s2) ? Inv($"{s1:F2}×{s2:F2} мм") : null);
|
||||
case ksBossRotatedDefinition:
|
||||
return ("revolve", null);
|
||||
case ksCutRotatedDefinition:
|
||||
return ("cut_revolve", "вырез вращением");
|
||||
case ksSketchDefinition:
|
||||
return ("sketch", null);
|
||||
}
|
||||
}
|
||||
catch { /* параметры/тип best-effort */ }
|
||||
|
||||
// Не распознанная операция: начало координат, импортированное тело либо иной узел-правка.
|
||||
return name.StartsWith("Начало", StringComparison.OrdinalIgnoreCase) ? ("origin", null) : ("other", null);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<BodyInfo> ReadBodies(ksPart part)
|
||||
{
|
||||
if (part.BodyCollection() is not ksBodyCollection bc) return Array.Empty<BodyInfo>();
|
||||
int n = bc.GetCount();
|
||||
var list = new List<BodyInfo>(n);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (bc.GetByIndex(i) is not ksBody b) continue;
|
||||
int faces = 0; try { if (b.FaceCollection() is ksFaceCollection fc) faces = fc.GetCount(); } catch { }
|
||||
bool solid = false; try { solid = b.IsSolid(); } catch { }
|
||||
list.Add(new BodyInfo { Index = i, IsSolid = solid, FaceCount = faces });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<VariableInfo> ReadVariables(ksPart part)
|
||||
{
|
||||
if (part.VariableCollection() is not ksVariableCollection vc) return Array.Empty<VariableInfo>();
|
||||
int n = vc.GetCount();
|
||||
var list = new List<VariableInfo>(n);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
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),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static (List<string> faceTypes, List<string> edgeTypes) ReadTopology(ksPart part)
|
||||
{
|
||||
var faceTypes = new List<string>();
|
||||
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 (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));
|
||||
return (faceTypes, edgeTypes);
|
||||
}
|
||||
|
||||
private static PartInfo? ReadMass(ksPart part)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (part.CalcMassInertiaProperties(MixMmKg) is not ksMassInertiaParam m) return null;
|
||||
return new PartInfo
|
||||
{
|
||||
Volume = m.v, Mass = m.m, Area = m.F,
|
||||
CenterX = m.xc, CenterY = m.yc, CenterZ = m.zc,
|
||||
};
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static (double X, double Y, double Z)? FaceNormal(ksFaceDefinition def)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (def.GetSurface() is not ksSurface surf) return null;
|
||||
double u = (surf.GetParamUMin() + surf.GetParamUMax()) / 2;
|
||||
double v = (surf.GetParamVMin() + surf.GetParamVMax()) / 2;
|
||||
if (!surf.GetNormal(u, v, out double nx, out double ny, out double nz)) return null;
|
||||
if (!def.normalOrientation) { nx = -nx; ny = -ny; nz = -nz; }
|
||||
return (nx, ny, nz);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static (double X, double Y, double Z)? Vertex(ksEdgeDefinition def, bool start)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (def.GetVertex(start) is ksVertexDefinition v && v.GetPoint(out double x, out double y, out double z))
|
||||
return (x, y, z);
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private object ResolveObject(ksPart part, string kind, int index)
|
||||
{
|
||||
var type = kind.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"face" => Obj3dType.o3d_face,
|
||||
"edge" => Obj3dType.o3d_edge,
|
||||
"vertex" => Obj3dType.o3d_vertex,
|
||||
_ => throw new ArgumentException($"Неизвестный тип объекта '{kind}'. Допустимо: face, edge, vertex."),
|
||||
};
|
||||
var coll = part.EntityCollection((short)type) as ksEntityCollection
|
||||
?? throw new InvalidOperationException($"Не удалось получить коллекцию {kind}.");
|
||||
if (index < 0 || index >= coll.GetCount())
|
||||
throw new ArgumentOutOfRangeException(nameof(index), $"Индекс {kind} вне диапазона [0; {coll.GetCount() - 1}].");
|
||||
return coll.GetByIndex(index) as ksEntity
|
||||
?? throw new InvalidOperationException($"Объект {kind}[{index}] не приводится к ksEntity.");
|
||||
}
|
||||
|
||||
// ── низкоуровневые помощники ──────────────────────────────────────────
|
||||
|
||||
private ksPart GetTopPart()
|
||||
{
|
||||
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).");
|
||||
}
|
||||
|
||||
private string ActiveDocName()
|
||||
{
|
||||
var doc = _session.Application.ActiveDocument;
|
||||
try { return doc?.Name ?? ""; }
|
||||
catch { return ""; }
|
||||
finally { Release(doc); }
|
||||
}
|
||||
|
||||
private static ksEntityCollection? Faces(ksPart part) => part.EntityCollection((short)Obj3dType.o3d_face) as ksEntityCollection;
|
||||
private static ksEntityCollection? Edges(ksPart part) => part.EntityCollection((short)Obj3dType.o3d_edge) as ksEntityCollection;
|
||||
|
||||
private static ksFaceDefinition FaceAt(ksEntityCollection? faces, int index)
|
||||
{
|
||||
if (faces is null) throw new InvalidOperationException("Не удалось получить коллекцию граней.");
|
||||
if (index < 0 || index >= faces.GetCount())
|
||||
throw new ArgumentOutOfRangeException(nameof(index), $"Индекс грани вне диапазона [0; {faces.GetCount() - 1}]. Сверьтесь с list_faces.");
|
||||
return (faces.GetByIndex(index) as ksEntity)?.GetDefinition() as ksFaceDefinition
|
||||
?? throw new InvalidOperationException($"Грань[{index}] не приводится к ksFaceDefinition.");
|
||||
}
|
||||
|
||||
private static ksEdgeDefinition EdgeAt(ksEntityCollection? edges, int index)
|
||||
{
|
||||
if (edges is null) throw new InvalidOperationException("Не удалось получить коллекцию рёбер.");
|
||||
if (index < 0 || index >= edges.GetCount())
|
||||
throw new ArgumentOutOfRangeException(nameof(index), $"Индекс ребра вне диапазона [0; {edges.GetCount() - 1}]. Сверьтесь с list_edges.");
|
||||
return (edges.GetByIndex(index) as ksEntity)?.GetDefinition() as ksEdgeDefinition
|
||||
?? 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,53 @@ public sealed class QueryService
|
||||
}
|
||||
}, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Перечислить компоненты верхнего уровня активной сборки (детали/подсборки): имя,
|
||||
/// обозначение, файл, признак детали и габарит. Обход через API7 <c>TopPart → Parts</c>.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<ComponentInfo>> ListComponentsAsync(CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync<IReadOnlyList<ComponentInfo>>(() =>
|
||||
{
|
||||
if (_session.Application.ActiveDocument is not KompasAPI7.IKompasDocument3D doc3d)
|
||||
throw new InvalidOperationException("Активный документ не является 3D-документом.");
|
||||
if (doc3d.TopPart is not KompasAPI7.IPart7 top)
|
||||
throw new InvalidOperationException("Не удалось получить TopPart активного документа.");
|
||||
|
||||
var parts = top.Parts;
|
||||
int count = parts.Count;
|
||||
var list = new List<ComponentInfo>(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (parts.Part[i] is not KompasAPI7.IPart7 p) continue;
|
||||
double x1 = 0, y1 = 0, z1 = 0, x2 = 0, y2 = 0, z2 = 0;
|
||||
bool gab = false;
|
||||
try { gab = p.GetGabarit(false, false, out x1, out y1, out z1, out x2, out y2, out z2); }
|
||||
catch { /* габарит может быть недоступен у отдельных компонентов */ }
|
||||
list.Add(new ComponentInfo
|
||||
{
|
||||
Index = i,
|
||||
Name = SafeGet(() => p.Name),
|
||||
Marking = SafeGet(() => p.Marking),
|
||||
FileName = SafeGet(() => p.FileName),
|
||||
IsDetail = SafeBool(() => p.Detail),
|
||||
SizeX = gab ? Math.Abs(x2 - x1) : 0,
|
||||
SizeY = gab ? Math.Abs(y2 - y1) : 0,
|
||||
SizeZ = gab ? Math.Abs(z2 - z1) : 0,
|
||||
});
|
||||
}
|
||||
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;
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Kompas.Mcp.Core.Query;
|
||||
|
||||
/// <summary>Переменная модели (имя, выражение, значение, признаки).</summary>
|
||||
public sealed record VariableInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Выражение переменной (может быть числом или формулой со ссылками на другие переменные).</summary>
|
||||
public required string Expression { get; init; }
|
||||
|
||||
/// <summary>Вычисленное числовое значение.</summary>
|
||||
public required double Value { get; init; }
|
||||
|
||||
/// <summary>Внешняя переменная (доступна из вышестоящих документов/исполнений).</summary>
|
||||
public required bool External { get; init; }
|
||||
|
||||
/// <summary>Информационная переменная (вычисляется, не задаёт геометрию).</summary>
|
||||
public required bool Information { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Kompas.Mcp.Core.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Расшифровка кодов ошибок построения КОМПАС (<c>ErrorType3d</c>), которые возвращает
|
||||
/// <c>IFeature7.ObjectError</c>. 0 — ошибки нет.
|
||||
/// </summary>
|
||||
public static class Errors3d
|
||||
{
|
||||
/// <summary>true, если код означает отсутствие ошибки.</summary>
|
||||
public static bool IsOk(int code) => code == 0;
|
||||
|
||||
/// <summary>Человекочитаемое описание кода ошибки построения.</summary>
|
||||
public static string Describe(int code) => code switch
|
||||
{
|
||||
0 => "ошибки нет",
|
||||
-7 => "документ не активизирован или не является деталью/сборкой",
|
||||
-1 => "аварийное завершение операции",
|
||||
54 => "неизвестная ошибка построения (операция не выполнена)",
|
||||
123 => "объекты пересекаются",
|
||||
130 => "ошибочная топология",
|
||||
135 => "не найдены одна или несколько исходных операций",
|
||||
144 => "невозможная операция",
|
||||
145 => "операция потеряла опорные объекты",
|
||||
179 => "контур невозможно использовать для заданного перемещения",
|
||||
185 => "объект не найден",
|
||||
193 => "доступ к объекту запрещён",
|
||||
199 => "параметр операции был изменён",
|
||||
202 => "ошибка при создании скругления грани",
|
||||
_ => $"ошибка построения #{code}",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Kompas.Mcp.Core.Validation;
|
||||
|
||||
/// <summary>Диагностика операции дерева построения, находящейся в ошибке/требующей перестроения.</summary>
|
||||
public sealed record OperationError
|
||||
{
|
||||
/// <summary>Имя операции в дереве.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Код ошибки построения (ErrorType3d); 0 — ошибки нет.</summary>
|
||||
public required int ErrorCode { get; init; }
|
||||
|
||||
/// <summary>Человекочитаемое описание кода.</summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>false — операция вырождена (не дала корректной геометрии).</summary>
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>true — операция помечена «требует перестроения» (флаг ksFSRebuild).</summary>
|
||||
public required bool NeedsRebuild { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Kompas.Mcp.Core.Threading;
|
||||
using Kompas6Constants;
|
||||
using KompasAPI7;
|
||||
|
||||
namespace Kompas.Mcp.Core.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Валидация построения активной детали/сборки: какие операции в ошибке или требуют перестроения.
|
||||
/// Читает <c>IFeature7.Valid</c> / <c>ObjectError</c> / <c>State</c> по дереву (<c>SubFeatures</c>).
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ValidationService
|
||||
{
|
||||
private const int KsFsRebuild = 0x2; // ksFeatureStateEnum.ksFSRebuild — «требует перестроения»
|
||||
|
||||
private readonly KompasSession _session;
|
||||
private readonly KompasDispatcher _dispatcher;
|
||||
|
||||
public ValidationService(KompasSession session, KompasDispatcher dispatcher)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверить активную деталь/сборку: вернуть список операций, которые вырождены (Valid=false),
|
||||
/// имеют код ошибки (ObjectError≠0) или помечены «требует перестроения». Пустой список — построение чистое.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<OperationError>> ValidatePartAsync(CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync<IReadOnlyList<OperationError>>(() =>
|
||||
{
|
||||
if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d)
|
||||
throw new InvalidOperationException("Активный документ не является 3D-документом.");
|
||||
if (doc3d.TopPart is not IFeature7 top)
|
||||
throw new InvalidOperationException("Не удалось получить TopPart как IFeature7.");
|
||||
|
||||
var result = new List<OperationError>();
|
||||
CheckFeature(top, result);
|
||||
foreach (var f in EnumerateSubFeatures(top))
|
||||
CheckFeature(f, result);
|
||||
return result;
|
||||
}, ct);
|
||||
|
||||
private static IEnumerable<IFeature7> EnumerateSubFeatures(IFeature7 root)
|
||||
{
|
||||
object? subs = null;
|
||||
try { subs = root.SubFeatures[ksTreeTypeEnum.ksOperTree, true, false]; }
|
||||
catch { yield break; }
|
||||
|
||||
if (subs is object[] arr)
|
||||
{
|
||||
foreach (var o in arr) if (o is IFeature7 f) yield return f;
|
||||
}
|
||||
else if (subs is System.Collections.IEnumerable en)
|
||||
{
|
||||
foreach (var o in en) if (o is IFeature7 f) yield return f;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckFeature(IFeature7 f, List<OperationError> acc)
|
||||
{
|
||||
bool valid = true;
|
||||
int err = 0, state = 0;
|
||||
try { valid = f.Valid; } catch { /* ignore */ }
|
||||
try { err = f.ObjectError; } catch { /* ignore */ }
|
||||
try { state = f.State; } catch { /* ignore */ }
|
||||
|
||||
bool needsRebuild = (state & KsFsRebuild) != 0;
|
||||
if (valid && Errors3d.IsOk(err) && !needsRebuild) return;
|
||||
|
||||
string name; try { name = f.Name ?? string.Empty; } catch { name = string.Empty; }
|
||||
acc.Add(new OperationError
|
||||
{
|
||||
Name = name,
|
||||
ErrorCode = err,
|
||||
Description = Errors3d.Describe(err),
|
||||
Valid = valid,
|
||||
NeedsRebuild = needsRebuild,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
using Kompas.Mcp.Core;
|
||||
using Kompas.Mcp.Core.Conversion;
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
using Kompas.Mcp.Core.Editing;
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
using Kompas.Mcp.Core.Query;
|
||||
using Kompas.Mcp.Core.Threading;
|
||||
using Kompas.Mcp.Core.Validation;
|
||||
using Kompas.Mcp.Core.Vision;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -21,6 +24,11 @@ builder.Services.AddSingleton<DocumentService>();
|
||||
builder.Services.AddSingleton<PartModeler>();
|
||||
builder.Services.AddSingleton<SnapshotService>();
|
||||
builder.Services.AddSingleton<QueryService>();
|
||||
builder.Services.AddSingleton<ModelInspectionService>();
|
||||
builder.Services.AddSingleton<ConversionService>();
|
||||
builder.Services.AddSingleton<FaceEditService>();
|
||||
builder.Services.AddSingleton<SolidEditService>();
|
||||
builder.Services.AddSingleton<ValidationService>();
|
||||
|
||||
// MCP-сервер поверх stdio; инструменты находятся по атрибутам в этой сборке.
|
||||
builder.Services
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.ComponentModel;
|
||||
using Kompas.Mcp.Core;
|
||||
using Kompas.Mcp.Core.Conversion;
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Kompas.Mcp.Host.Tools;
|
||||
|
||||
/// <summary>Инструменты обмена форматами: импорт/экспорт STEP.</summary>
|
||||
[McpServerToolType]
|
||||
public sealed class ConversionTools(KompasSession session, ConversionService conversion)
|
||||
{
|
||||
[McpServerTool(Name = "import_step")]
|
||||
[Description("Импортировать STEP-файл (.step/.stp) в новый документ и сделать его активным. " +
|
||||
"type: assembly (по умолчанию) | part — тип целевого документа. " +
|
||||
"createComponentFiles=true записывает компоненты сборки отдельными .m3d рядом с исходным STEP " +
|
||||
"(нужно, чтобы потом открыть/редактировать отдельную деталь). Возвращает тип созданного документа.")]
|
||||
public async Task<string> ImportStep(
|
||||
[Description("Полный путь к .step/.stp")] string path,
|
||||
[Description("Тип документа: assembly | part")] string type = "assembly",
|
||||
[Description("Создавать файлы компонентов сборки рядом со STEP")] bool createComponentFiles = false)
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var docType = DocumentTypes.Parse(type);
|
||||
await conversion.ImportStepAsync(path, docType, createComponentFiles);
|
||||
return $"STEP импортирован в документ типа {docType}: {path}";
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "export_step")]
|
||||
[Description("Экспортировать активный 3D-документ (деталь/сборку) в STEP по указанному пути. " +
|
||||
"format: auto (по умолчанию) | ap203 | ap214 | ap242.")]
|
||||
public async Task<string> ExportStep(
|
||||
[Description("Полный путь к выходному .step")] string path,
|
||||
[Description("Стандарт STEP: auto | ap203 | ap214 | ap242")] string format = "auto")
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var fmt = StepFormats.Parse(format);
|
||||
await conversion.ExportStepAsync(path, fmt);
|
||||
return $"Экспортировано в STEP ({fmt}): {path}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.ComponentModel;
|
||||
using Kompas.Mcp.Core;
|
||||
using Kompas.Mcp.Core.Editing;
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
using Kompas.Mcp.Core.Validation;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Kompas.Mcp.Host.Tools;
|
||||
|
||||
/// <summary>Инструменты прямого редактирования тела (без дерева построения).</summary>
|
||||
[McpServerToolType]
|
||||
public sealed class EditTools(
|
||||
KompasSession session,
|
||||
FaceEditService faceEdit,
|
||||
SolidEditService solidEdit,
|
||||
ValidationService validation)
|
||||
{
|
||||
[McpServerTool(Name = "move_face")]
|
||||
[Description("Прямое редактирование («Переместить грань»): сдвинуть грань детали, найденную по мировой точке " +
|
||||
"(x,y,z в мм, точка должна лежать на грани), на distance мм вдоль её нормали. distance>0 — наружу " +
|
||||
"(добавить материал, удлинить деталь), distance<0 — внутрь. Направление нормали подбирается автоматически. " +
|
||||
"Работает и на импортированной (STEP) геометрии без дерева построения. Перед вызовом сделайте " +
|
||||
"model_snapshot/list_faces и прикиньте точку на нужной грани.")]
|
||||
public async Task<string> MoveFace(double x, double y, double z, double distance)
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
await faceEdit.MoveFaceAsync(x, y, z, distance);
|
||||
return await WithValidation($"Грань в точке ({x}, {y}, {z}) перемещена на {distance} мм вдоль нормали.");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "split_solid_by_plane")]
|
||||
[Description("Рассечь тело активной детали смещённой плоскостью: база plane (XOY|XOZ|YOZ) + offset мм. " +
|
||||
"Получаются отдельные тела — возвращает их список с индексом и габаритом по осям, чтобы выбрать " +
|
||||
"тело для move_body. Шаг 1 метода «рассечь→раздвинуть→объединить».")]
|
||||
public async Task<string> SplitSolidByPlane(
|
||||
[Description("Базовая плоскость: XOY | XOZ | YOZ")] string plane,
|
||||
[Description("Смещение плоскости от базовой, мм")] double offset,
|
||||
[Description("Прямое направление отступа")] bool direction = true)
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var bodies = await solidEdit.SplitByPlaneAsync(BasePlanes.Parse(plane), offset, direction);
|
||||
var lines = bodies.Select(b =>
|
||||
$" [{b.Index}] габ {b.SizeX:F1}×{b.SizeY:F1}×{b.SizeZ:F1} мм, " +
|
||||
$"Z[{b.MinZ:F1}; {b.MaxZ:F1}]");
|
||||
return $"Рассечение {plane}+{offset}: тел стало {bodies.Count}\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "move_body")]
|
||||
[Description("Переместить тело (по индексу из split_solid_by_plane) на вектор (dx, dy, dz) мм. " +
|
||||
"Шаг 2 метода «рассечь→раздвинуть→объединить» — раздвигает части на нужный зазор.")]
|
||||
public async Task<string> MoveBody(int bodyIndex, double dx = 0, double dy = 0, double dz = 0)
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
await solidEdit.MoveBodyAsync(bodyIndex, dx, dy, dz);
|
||||
return await WithValidation($"Тело [{bodyIndex}] перемещено на ({dx}, {dy}, {dz}) мм.");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "boolean_union")]
|
||||
[Description("Объединить все тела активной детали в одно (булева операция «объединение»). " +
|
||||
"Шаг 4 (финал) метода «рассечь→раздвинуть→объединить»: после стыковки граней (move_face) " +
|
||||
"сливает части в единое тело.")]
|
||||
public async Task<string> BooleanUnion()
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
await solidEdit.UnionAllAsync();
|
||||
return await WithValidation("Тела объединены.");
|
||||
}
|
||||
|
||||
/// <summary>Дописать к сообщению результат проверки построения (операция могла «пройти», оставив ошибку).</summary>
|
||||
private async Task<string> WithValidation(string baseMsg)
|
||||
{
|
||||
var errs = await validation.ValidatePartAsync();
|
||||
if (errs.Count == 0) return baseMsg + " Построение чистое.";
|
||||
var lines = errs.Select(e => $"\"{e.Name}\" (код {e.ErrorCode}: {e.Description})");
|
||||
return baseMsg + $" ⚠ Внимание: {errs.Count} операц. в ошибке: " + string.Join("; ", lines) +
|
||||
". Результат может быть некорректным.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.ComponentModel;
|
||||
using Kompas.Mcp.Core;
|
||||
using Kompas.Mcp.Core.Query;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Kompas.Mcp.Host.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Структурное «зрение» агента — чтение модели как данных, а не картинки.
|
||||
/// Предпочтительнее снимка (<c>model_snapshot</c>): точнее и дешевле по контексту.
|
||||
/// </summary>
|
||||
[McpServerToolType]
|
||||
public sealed class InspectionTools(KompasSession session, ModelInspectionService inspection)
|
||||
{
|
||||
[McpServerTool(Name = "describe_model")]
|
||||
[Description("ПРЕДПОЧТИТЕЛЬНЫЙ способ «увидеть» активную деталь — структурный паспорт одним вызовом: " +
|
||||
"габарит, МЦХ, тела, сводка топологии (грани/рёбра по типам), дерево построения с параметрами и переменные. " +
|
||||
"Вызывайте ПЕРЕД model_snapshot: это точнее снимка и дешевле по контексту. Снимок берите только для " +
|
||||
"визуально-пространственных вопросов, на которые паспорт не отвечает.")]
|
||||
public async Task<string> DescribeModel()
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var d = await inspection.DescribeModelAsync();
|
||||
return InspectionText.Render(d);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "list_features")]
|
||||
[Description("Дерево построения активной детали по порядку: индекс, имя операции и ключевые параметры " +
|
||||
"(глубина/радиус/катеты). Для импортированных моделей (STEP) истории нет — только начало координат и тело.")]
|
||||
public async Task<string> ListFeatures()
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var fs = await inspection.ListFeaturesAsync();
|
||||
if (fs.Count == 0) return "Дерево построения пусто.";
|
||||
var lines = fs.Select(f => $" [{f.Index}] «{f.Name}»" + (string.IsNullOrEmpty(f.Detail) ? "" : $" — {f.Detail}"));
|
||||
return $"Операций: {fs.Count}\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "list_bodies")]
|
||||
[Description("Тела активной детали: индекс, признак твёрдого тела и число граней. Полезно для многотельных деталей.")]
|
||||
public async Task<string> ListBodies()
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var bs = await inspection.ListBodiesAsync();
|
||||
if (bs.Count == 0) return "Тел нет (модель пуста?).";
|
||||
var lines = bs.Select(b => $" [{b.Index}] {(b.IsSolid ? "твёрдое" : "поверхность")}, граней {b.FaceCount}");
|
||||
return $"Тел: {bs.Count}\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "list_variables")]
|
||||
[Description("Переменные активной детали: имя, выражение, значение и признаки (внешняя/информационная). " +
|
||||
"Это «ручки управления» параметрической модели.")]
|
||||
public async Task<string> ListVariables()
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var vs = await inspection.ListVariablesAsync();
|
||||
if (vs.Count == 0) return "Переменных нет.";
|
||||
var lines = vs.Select(v =>
|
||||
{
|
||||
var flags = (v.External ? " [внешняя]" : "") + (v.Information ? " [инфо]" : "");
|
||||
return Inv($" {v.Name} = {v.Expression} (= {v.Value:G}){flags}");
|
||||
});
|
||||
return $"Переменных: {vs.Count}\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "describe_face")]
|
||||
[Description("Детали грани по индексу из list_faces: тип, площадь, нормаль в центре, радиус (для цилиндра) " +
|
||||
"и число ограничивающих рёбер. Углубляет list_faces без угадывания координат.")]
|
||||
public async Task<string> DescribeFace([Description("Индекс грани из list_faces")] int faceIndex)
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var f = await inspection.DescribeFaceAsync(faceIndex);
|
||||
var sb = Inv($"Грань [{f.Index}]: {f.Type}, площадь {f.Area:F2} мм², ограничивающих рёбер: {f.EdgeCount}");
|
||||
if (f.Radius is { } r) sb += Inv($", радиус {r:F2} мм");
|
||||
if (f.Normal is { } n) sb += Inv($", нормаль ({n.X:F3}, {n.Y:F3}, {n.Z:F3})");
|
||||
return sb;
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "describe_edge")]
|
||||
[Description("Детали ребра по индексу из list_edges: тип, длина, две смежные грани и координаты концевых вершин.")]
|
||||
public async Task<string> DescribeEdge([Description("Индекс ребра из list_edges")] int edgeIndex)
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var e = await inspection.DescribeEdgeAsync(edgeIndex);
|
||||
var sb = Inv($"Ребро [{e.Index}]: {e.Type}, длина {e.Length:F2} мм");
|
||||
if (e.AdjacentFace1 is not null || e.AdjacentFace2 is not null)
|
||||
sb += $", смежные грани: {e.AdjacentFace1 ?? "?"} / {e.AdjacentFace2 ?? "?"}";
|
||||
if (e.Start is { } s && e.End is { } en)
|
||||
sb += Inv($", концы ({s.X:F2}, {s.Y:F2}, {s.Z:F2}) → ({en.X:F2}, {en.Y:F2}, {en.Z:F2})");
|
||||
return sb;
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "measure")]
|
||||
[Description("Измерить расстояние и угол между двумя объектами активной детали. kind1/kind2: face|edge|vertex; " +
|
||||
"индексы — из list_faces/list_edges. Возвращает минимальное расстояние (мм) и угол (°, если применим).")]
|
||||
public async Task<string> Measure(
|
||||
[Description("Тип первого объекта: face|edge|vertex")] string kind1,
|
||||
[Description("Индекс первого объекта")] int index1,
|
||||
[Description("Тип второго объекта: face|edge|vertex")] string kind2,
|
||||
[Description("Индекс второго объекта")] int index2)
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var m = await inspection.MeasureAsync(kind1, index1, kind2, index2);
|
||||
var angle = m.AngleValid ? Inv($", угол {m.Angle:F2}°") : " (угол неприменим)";
|
||||
return Inv($"{kind1}[{index1}] ↔ {kind2}[{index2}]: расстояние {m.MinDistance:F2} мм{angle}");
|
||||
}
|
||||
|
||||
private static string Inv(FormattableString fs) => FormattableString.Invariant(fs);
|
||||
}
|
||||
@@ -19,6 +19,22 @@ public sealed class QueryTools(KompasSession session, QueryService query)
|
||||
$"центр масс: ({i.CenterX:F2}, {i.CenterY:F2}, {i.CenterZ:F2}).";
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "list_components")]
|
||||
[Description("Перечислить компоненты верхнего уровня активной сборки: индекс, имя, обозначение, признак детали и габарит (мм). Индекс/имя — для последующего извлечения или осмотра детали.")]
|
||||
public async Task<string> ListComponents()
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var comps = await query.ListComponentsAsync();
|
||||
if (comps.Count == 0) return "Компонентов нет (активный документ не сборка или пуст).";
|
||||
var lines = comps.Select(c =>
|
||||
{
|
||||
var mark = string.IsNullOrWhiteSpace(c.Marking) ? "" : $", обозн. \"{c.Marking}\"";
|
||||
return $" [{c.Index}] {(c.IsDetail ? "деталь" : "узел ")} \"{c.Name}\", " +
|
||||
$"габ {c.SizeX:F1}×{c.SizeY:F1}×{c.SizeZ:F1} мм{mark}";
|
||||
});
|
||||
return $"Компонентов: {comps.Count}\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "list_faces")]
|
||||
[Description("Перечислить грани активной детали: индекс, тип поверхности (plane/cylinder/cone/sphere/torus/nurbs) и площадь (мм²). Индекс используйте в sketch_create_on_face_index. Удобно для надёжного выбора грани без угадывания координат.")]
|
||||
public async Task<string> ListFaces()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel;
|
||||
using Kompas.Mcp.Core;
|
||||
using Kompas.Mcp.Core.Validation;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Kompas.Mcp.Host.Tools;
|
||||
|
||||
/// <summary>Инструменты валидации построения.</summary>
|
||||
[McpServerToolType]
|
||||
public sealed class ValidationTools(KompasSession session, ValidationService validation)
|
||||
{
|
||||
[McpServerTool(Name = "validate_part")]
|
||||
[Description("Проверить построение активной детали/сборки: вернуть операции, которые в ошибке (вырождены), " +
|
||||
"имеют код ошибки ErrorType3d или помечены «требует перестроения» (красный «!» в дереве). " +
|
||||
"Пустой результат — построение чистое. Вызывай после операций редактирования (move_face, extrude и т.п.).")]
|
||||
public async Task<string> ValidatePart()
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var errs = await validation.ValidatePartAsync();
|
||||
if (errs.Count == 0) return "Построение чистое: операций в ошибке нет.";
|
||||
var lines = errs.Select(e =>
|
||||
{
|
||||
var flags = (e.Valid ? "" : "вырождена; ") + (e.NeedsRebuild ? "требует перестроения; " : "");
|
||||
return $" • \"{e.Name}\": {flags}код {e.ErrorCode} — {e.Description}";
|
||||
});
|
||||
return $"Проблемных операций: {errs.Count}\n" + string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ namespace Kompas.Mcp.Host.Tools;
|
||||
public sealed class VisionTools(KompasSession session, SnapshotService snapshot)
|
||||
{
|
||||
[McpServerTool(Name = "model_snapshot")]
|
||||
[Description("Снять текущий вид активной 3D-модели в PNG и вернуть как изображение — чтобы увидеть результат построения и решить следующий шаг.")]
|
||||
[Description("Растровый снимок активной 3D-модели в PNG — FALLBACK. Сначала используйте describe_model (структурный паспорт: " +
|
||||
"дерево, топология, габарит, МЦХ) — он точнее и дешевле. Снимок берите только для визуально-пространственных " +
|
||||
"вопросов, на которые структурные запросы не отвечают: взаимное расположение, ориентация, общая форма.")]
|
||||
public async Task<ImageContentBlock> Snapshot(
|
||||
[Description("Разрешение в DPI; 0 — экранное")] int resolution = 0)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user