Files
kompas3d-mcp/src/Kompas.Mcp.Core/Assemblies/AssemblyService.cs
T
mikhail 8282716773 feat: assembly_add_mate — сопряжения компонентов сборки (API7)
Веха СБОРКИ, инкремент 2. AssemblyService.AddMateAsync (API7): top.MateConstraints
→ Add(MateConstraintType) → BaseObject1/2 (грани через FindObjectsByPoint
FirstLevel=false) → ParamValue → Update → RebuildModel. Свой enum MateType
(Coincidence/Distance) → MateConstraintType. MCP-инструмент assembly_add_mate.

Подтверждено спайком и тестами: coincidence сводит грани заподлицо (ширина 120→40),
distance(30) держит зазор 30 (→70); Update=True Valid=True. Решатель может двигать
любой компонент → проверка по ширине габарита.

Ревью Codex учтено: scope урезан до coincidence/distance (только геометрически
проверенные); валидация value по типу (RequireMateValue); полный откат при любой
ошибке после Add (mate.Owner.Delete); возврат AddedMate{Type,Valid}; усиленные
ассерты (Valid, размеры компонентов неизменны). Точечный release RCW отклонён
(конвенция v2-2). Прочие типы (parallel/concentric/angle/…) — будущие инкременты.

Тесты: +14 unit (Mates.Parse/RequiresValue, RequireMateValue), +2 integration
(AssemblyMateTests). Итого 97 unit + 57 integration = 154 зелёных.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:39:04 +03:00

200 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Threading;
using Kompas6Constants;
using Kompas6Constants3D;
using KompasAPI7;
namespace Kompas.Mcp.Core.Assemblies;
/// <summary>
/// Операции со сборками через API7 (<c>IParts7</c>). Аналогично <c>HoleService</c>/<c>FaceEditService</c>:
/// контейнеры берутся COM-QI, все вызовы — на STA-потоке <see cref="KompasDispatcher"/>.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class AssemblyService
{
private readonly KompasSession _session;
private readonly KompasDispatcher _dispatcher;
public AssemblyService(KompasSession session, KompasDispatcher dispatcher)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
}
/// <summary>
/// Вставить компонент (деталь .m3d или подсборку .a3d) из файла в активную сборку, разместив
/// его origin в мировой точке (<paramref name="x"/>,<paramref name="y"/>,<paramref name="z"/>) мм.
/// </summary>
public Task<AddedComponent> AddComponentAsync(string filePath, double x, double y, double z, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => AddComponentCore(filePath, x, y, z), ct);
private AddedComponent AddComponentCore(string filePath, double x, double y, double z)
{
var path = AssemblyValidation.NormalizeComponentPath(filePath);
AssemblyValidation.RequireFinitePosition(x, y, z);
if (!File.Exists(path))
throw new FileNotFoundException($"Файл компонента не найден: {path}", path);
var top = RequireActiveAssembly();
var parts = top.Parts
?? throw new InvalidOperationException("Не удалось получить коллекцию компонентов сборки (Parts).");
// Фиксируем число компонентов ДО вставки — для верифицируемого отката (откатываем всё, что
// появилось сверх beforeCount), не полагаясь на возможный null-возврат AddFromFile.
int beforeCount = parts.Count;
// ExternalFile=true: компонент ссылается на внешний .m3d/.a3d. Redraw=true: вставить и
// перерисовать в исходном положении (само перемещение применит SetOrigin+RebuildModel ниже).
var part = parts.AddFromFile(path, true, true) as IPart7;
if (part is null)
{
// AddFromFile мог вставить компонент и всё равно вернуть null/не-IPart7 — почистим, если
// число компонентов выросло, чтобы не оставить «осиротевший» компонент в сборке.
RollbackInsertion(top, parts, beforeCount);
throw new InvalidOperationException(
$"Не удалось вставить компонент из файла (AddFromFile вернул null или не IPart7): {path}");
}
try
{
var placement = part.Placement as IPlacement3D
?? throw new InvalidOperationException("Placement компонента не приводится к IPlacement3D.");
if (!placement.SetOrigin(x, y, z))
throw new InvalidOperationException("SetOrigin вернул FALSE — не удалось задать положение компонента.");
// UpdatePlacement применяет изменение Placement; для вручную позиционируемого компонента
// (без сопряжений/зависимостей) он может вернуть FALSE — это НЕ ошибка (нечего
// пересчитывать). Фактически положение становится видимым в геометрии после RebuildModel.
part.UpdatePlacement(true);
if (!top.RebuildModel(true))
throw new InvalidOperationException("RebuildModel вернул FALSE — сборка не перестроена после вставки компонента.");
return new AddedComponent { Index = parts.Count - 1, Name = SafeName(part) };
}
catch
{
// Верифицируемый откол: удаляем всё, что добавилось сверх beforeCount, и проверяем.
RollbackInsertion(top, parts, beforeCount);
throw;
}
}
/// <summary>
/// Наложить сопряжение <paramref name="type"/> между гранями двух компонентов активной сборки,
/// выбранными по мировым точкам (<paramref name="x1"/>,<paramref name="y1"/>,<paramref name="z1"/>)
/// и (<paramref name="x2"/>,<paramref name="y2"/>,<paramref name="z2"/>). Для расстояния
/// <paramref name="value"/> — зазор (мм). Решатель спозиционирует компоненты; результат — статус
/// построенного сопряжения.
/// </summary>
public Task<AddedMate> AddMateAsync(MateType type, double x1, double y1, double z1,
double x2, double y2, double z2, double value, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => AddMateCore(type, x1, y1, z1, x2, y2, z2, value), ct);
private AddedMate AddMateCore(MateType type, double x1, double y1, double z1,
double x2, double y2, double z2, double value)
{
AssemblyValidation.RequireFinitePosition(x1, y1, z1);
AssemblyValidation.RequireFinitePosition(x2, y2, z2);
AssemblyValidation.RequireMateValue(Mates.RequiresValue(type), value);
var top = RequireActiveAssembly();
var face1 = FindFaceInAssembly(top, x1, y1, z1)
?? throw new InvalidOperationException(
$"В точке ({x1},{y1},{z1}) не найдена грань компонента. Укажите точку в середине грани (model_snapshot).");
var face2 = FindFaceInAssembly(top, x2, y2, z2)
?? throw new InvalidOperationException(
$"В точке ({x2},{y2},{z2}) не найдена грань компонента. Укажите точку в середине грани (model_snapshot).");
var mates = top.MateConstraints
?? throw new InvalidOperationException("Не удалось получить коллекцию сопряжений сборки (MateConstraints).");
var mate = mates.Add(Mates.ToConstraintType(type))
?? throw new InvalidOperationException("MateConstraints.Add вернул null.");
try
{
mate.BaseObject1 = (IModelObject)face1;
mate.BaseObject2 = (IModelObject)face2;
if (Mates.RequiresValue(type))
mate.ParamValue = value;
if (!mate.Update())
throw new InvalidOperationException(
"Update сопряжения вернул FALSE — связь не наложена (несовместимые грани/значение?).");
if (!top.RebuildModel(true))
throw new InvalidOperationException("RebuildModel вернул FALSE — сборка не перестроена после сопряжения.");
return new AddedMate { Type = type, Valid = mate.Valid };
}
catch
{
// Полный откат: при ЛЮБОЙ ошибке после Add удаляем сопряжение, чтобы не оставить «сироту».
try { mate.Owner?.Delete(); top.RebuildModel(true); } catch { /* лучшее усилие */ }
throw;
}
}
private static IFace? FindFaceInAssembly(IPart7 top, double x, double y, double z)
{
// FirstLevel=false — спускаемся в компоненты, чтобы получить их грани в контексте сборки.
var hit = top.FindObjectsByPoint(x, y, z, false);
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;
}
}
/// <summary>
/// Откатить вставку: удалить компоненты, появившиеся сверх <paramref name="beforeCount"/> (обычно
/// один — только что вставленный). Best-effort, но с верификацией: компонент удаляется через
/// <c>IFeature7.Delete()</c> своего владельца, затем сборка перестраивается.
/// </summary>
private static void RollbackInsertion(IPart7 top, IParts7 parts, int beforeCount)
{
try
{
// Удаляем с конца, пока число не вернётся к исходному (защита от частичной вставки).
for (int guard = 0; parts.Count > beforeCount && guard < 8; guard++)
{
if (parts.Part[parts.Count - 1] is IPart7 last && last.Owner is { } owner)
owner.Delete();
else
break; // не удалось получить владельца — выходим, чтобы не зациклиться
}
top.RebuildModel(true);
}
catch { /* лучшее усилие: исходное исключение важнее ошибки отката */ }
}
/// <summary>
/// Получить верхний компонент активной сборки, проверив тип документа. Раздельные ошибки:
/// нет документа / документ не сборка / не 3D / нет TopPart. Деталь — тоже IKompasDocument3D,
/// поэтому отсекаем по DocumentType ДО доступа к TopPart.
/// </summary>
private IPart7 RequireActiveAssembly()
{
var doc = _session.Application.ActiveDocument
?? throw new InvalidOperationException("Нет активного документа. Создайте сборку: document_create assembly.");
if (doc.DocumentType != DocumentTypeEnum.ksDocumentAssembly)
throw new InvalidOperationException(
$"Активный документ не является сборкой (тип: {doc.DocumentType}). Создайте сборку: document_create assembly.");
if (doc is not IKompasDocument3D doc3d)
throw new InvalidOperationException("Активный документ не является 3D-документом.");
return doc3d.TopPart as IPart7
?? throw new InvalidOperationException("Не удалось получить TopPart сборки.");
}
private static string SafeName(IPart7 part)
{
try { return part.Name ?? string.Empty; }
catch { return string.Empty; }
}
}