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>
This commit is contained in:
2026-05-27 13:39:04 +03:00
parent 8afad88241
commit 8282716773
8 changed files with 324 additions and 3 deletions
@@ -1,7 +1,27 @@
# Дизайн: сопряжение компонентов сборки (assembly_add_mate) через API7
**Дата:** 2026-05-27
**Статус:** спайк зелёный (механизм подтверждён вживую); спроектировано; на ревью (Codex)
**Статус:** спайк зелёный; ревью Codex учтено (8 замечаний); реализуется
## Решения по ревью Codex (важные правки к дизайну ниже)
- **Scope урезан до `coincidence` + `distance`** (#6, #5): только эти два типа геометрически
подтверждены спайком → только они в публичном `MateType`/`Parse`. Остальные (parallel/
perpendicular/concentric/angle/tangency) и `Alignment`-параметр — будущие инкременты с тестами.
- **Валидация value по типу** (#1): `Mates.RequiresValue` → distance требует `value` конечный и
`≥ 0`; для типов без value параметр игнорируется (дефолт 0). Валидатор
`AssemblyValidation.RequireMateValue(requiresValue, value)`.
- **Полный откат** (#3): весь участок после `mates.Add` — в try/catch; при ЛЮБОЙ ошибке (BaseObject,
ParamValue, Update=FALSE, RebuildModel=FALSE) сопряжение удаляется через `mate.Owner?.Delete()`
(`IFeature7`, как у компонента в инкременте 1).
- **Возврат результата** (#8b): `AddedMate { Type: string, Valid: bool }` — для отчёта инструмента и
ассертов теста (`Valid`).
- **Усиленный тест** (#2): явно `bbBefore ≈ 120`; после mate — `Valid==true`, размеры каждого
компонента (`SizeX≈20`) неизменны, ширина сборки изменилась.
- **RCW** (#7): точечный release НЕ вводим (конвенция v2-2, как в инкременте 1/HoleService); все
COM-вызовы — внутри `dispatcher.InvokeAsync` (уже так). Долг зафиксирован в OPEN_QUESTIONS v2-2.
- **Выбор грани** (#8a): паттерн «первый IFace» (как в HoleService/move_face); в описании
инструмента — «точка в СЕРЕДИНЕ грани» (на ребре/углу хит неоднозначен).
## Цель
@@ -0,0 +1,11 @@
namespace Kompas.Mcp.Core.Assemblies;
/// <summary>Результат наложения сопряжения между компонентами сборки.</summary>
public sealed record AddedMate
{
/// <summary>Тип наложенного сопряжения (как был запрошен).</summary>
public required MateType Type { get; init; }
/// <summary>true — сопряжение успешно построено и не вырождено после перестроения.</summary>
public required bool Valid { get; init; }
}
@@ -1,6 +1,7 @@
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Threading;
using Kompas6Constants;
using Kompas6Constants3D;
using KompasAPI7;
namespace Kompas.Mcp.Core.Assemblies;
@@ -79,6 +80,77 @@ public sealed class AssemblyService
}
}
/// <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, но с верификацией: компонент удаляется через
@@ -29,10 +29,22 @@ public static class AssemblyValidation
return Path.GetFullPath(trimmed);
}
/// <summary>Проверить, что координаты вставки конечны (отсекает NaN/Infinity).</summary>
/// <summary>Проверить, что координаты конечны (отсекает NaN/Infinity).</summary>
public static void RequireFinitePosition(double x, double y, double z)
{
if (!double.IsFinite(x) || !double.IsFinite(y) || !double.IsFinite(z))
throw new ArgumentException("Координаты вставки компонента должны быть конечными числами (не NaN/Infinity).");
throw new ArgumentException("Координаты должны быть конечными числами (не NaN/Infinity).");
}
/// <summary>
/// Проверить числовое значение сопряжения. Если значение требуется (<paramref name="requiresValue"/>,
/// напр. расстояние) — оно должно быть конечным и неотрицательным.
/// </summary>
public static void RequireMateValue(bool requiresValue, double value)
{
if (!requiresValue) return; // для типов без параметра значение игнорируется
if (!double.IsFinite(value) || value < 0)
throw new ArgumentOutOfRangeException(nameof(value),
"Значение сопряжения (расстояние) должно быть конечным и неотрицательным.");
}
}
@@ -0,0 +1,44 @@
using Kompas6Constants3D;
namespace Kompas.Mcp.Core.Assemblies;
/// <summary>
/// Дружелюбный тип сопряжения (обёртка над <see cref="MateConstraintType"/>). Пока поддержаны только
/// геометрически проверенные типы — совпадение и расстояние; остальные (параллельность, соосность,
/// угол и т.д.) добавятся в следующих инкрементах вместе с тестами.
/// </summary>
public enum MateType
{
/// <summary>Совпадение (грани лежат на одной плоскости, заподлицо).</summary>
Coincidence,
/// <summary>На расстоянии (постоянный зазор между гранями, мм).</summary>
Distance,
}
/// <summary>Маппинг дружелюбного типа сопряжения ↔ перечисление API КОМПАС и разбор строки.</summary>
public static class Mates
{
/// <summary>Сопряжения, которым нужно числовое значение (<c>ParamValue</c>): расстояние.</summary>
public static bool RequiresValue(MateType type) => type is MateType.Distance;
public static MateConstraintType ToConstraintType(MateType type) => type switch
{
MateType.Coincidence => MateConstraintType.mc_Coincidence,
MateType.Distance => MateConstraintType.mc_Distance,
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Неизвестный тип сопряжения."),
};
/// <summary>Разобрать строку типа сопряжения (англ./рус.) в <see cref="MateType"/>.</summary>
public static MateType Parse(string value)
{
ArgumentNullException.ThrowIfNull(value);
return value.Trim().ToLowerInvariant() switch
{
"coincidence" or "coincident" or "совпадение" => MateType.Coincidence,
"distance" or "расстояние" => MateType.Distance,
_ => throw new ArgumentException(
$"Неизвестный/неподдерживаемый тип сопряжения '{value}'. Сейчас поддержаны: " +
"coincidence (совпадение), distance (расстояние).", nameof(value)),
};
}
}
@@ -24,4 +24,26 @@ public sealed class AssemblyTools(KompasSession session, AssemblyService assembl
var c = await assembly.AddComponentAsync(filePath, x, y, z);
return $"Компонент '{c.Name}' вставлен в сборку, индекс {c.Index}, позиция ({x}, {y}, {z}).";
}
[McpServerTool(Name = "assembly_add_mate")]
[Description("Наложить сопряжение между гранями ДВУХ компонентов активной сборки, выбранными по " +
"мировым точкам (x1,y1,z1) и (x2,y2,z2) — точки в СЕРЕДИНЕ граней (мм). mateType: " +
"'coincidence' (совпадение, грани заподлицо) или 'distance' (на расстоянии value мм между гранями). " +
"Решатель спозиционирует компоненты. Сделайте model_snapshot/describe_face, прикиньте точки на гранях.")]
public async Task<string> AddMate(
[Description("Тип сопряжения: coincidence | distance")] string mateType,
[Description("X точки на грани компонента 1, мм")] double x1,
[Description("Y точки на грани компонента 1, мм")] double y1,
[Description("Z точки на грани компонента 1, мм")] double z1,
[Description("X точки на грани компонента 2, мм")] double x2,
[Description("Y точки на грани компонента 2, мм")] double y2,
[Description("Z точки на грани компонента 2, мм")] double z2,
[Description("Зазор для distance, мм (для coincidence не нужен)")] double value = 0)
{
await session.ConnectAsync();
var type = Mates.Parse(mateType);
var m = await assembly.AddMateAsync(type, x1, y1, z1, x2, y2, z2, value);
var valueText = Mates.RequiresValue(type) ? $", значение {value} мм" : "";
return $"Сопряжение '{type}'{valueText} наложено (valid={m.Valid}).";
}
}
@@ -41,4 +41,46 @@ public sealed class AssemblyValidationTests
[Fact]
public void RequireFinitePosition_accepts_finite()
=> Assert.Null(Record.Exception(() => AssemblyValidation.RequireFinitePosition(50, -10, 0)));
// ── Сопряжения (Mates) ───────────────────────────────────────────────
[Theory]
[InlineData("coincidence", MateType.Coincidence)]
[InlineData("совпадение", MateType.Coincidence)]
[InlineData("DISTANCE", MateType.Distance)]
[InlineData(" расстояние ", MateType.Distance)]
public void Mates_Parse_maps_known(string text, MateType expected)
=> Assert.Equal(expected, Mates.Parse(text));
[Theory]
[InlineData("parallel")] // пока не поддержан
[InlineData("сопряжение")]
[InlineData("")]
public void Mates_Parse_throws_on_unknown(string text)
=> Assert.Throws<ArgumentException>(() => Mates.Parse(text));
// Корректность ToConstraintType проверяется интеграционно (coincidence даёт прилегание,
// distance — зазор), а не unit-ассертом: КОМПАС-enum встроен (EmbedInteropTypes) и не виден
// тестовому проекту через границу сборки.
[Theory]
[InlineData(MateType.Distance, true)]
[InlineData(MateType.Coincidence, false)]
public void Mates_RequiresValue(MateType type, bool expected)
=> Assert.Equal(expected, Mates.RequiresValue(type));
[Fact]
public void RequireMateValue_ignores_value_when_not_required()
=> Assert.Null(Record.Exception(() => AssemblyValidation.RequireMateValue(false, double.NaN)));
[Theory]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(-1)]
public void RequireMateValue_throws_on_bad_required_value(double value)
=> Assert.Throws<ArgumentOutOfRangeException>(() => AssemblyValidation.RequireMateValue(true, value));
[Fact]
public void RequireMateValue_accepts_nonnegative_required_value()
=> Assert.Null(Record.Exception(() => AssemblyValidation.RequireMateValue(true, 30)));
}
@@ -0,0 +1,98 @@
using Kompas.Mcp.Core.Assemblies;
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>Интеграция: сопряжения компонентов сборки (API7 IMateConstraints3D).</summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class AssemblyMateTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PartModeler _modeler;
private readonly AssemblyService _assembly;
private readonly QueryService _query;
public AssemblyMateTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_assembly = new AssemblyService(fx.Session, fx.Dispatcher);
_query = new QueryService(fx.Session, fx.Dispatcher);
}
private async Task<string> BuildAndSaveCubeAsync(double side)
{
var path = TestPaths.NewFile(".m3d");
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddRectangleAsync(s, 0, 0, side, side);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: side);
await _modeler.RebuildAsync();
await _docs.SaveAsAsync(path);
}
finally { await _docs.CloseAsync(save: false); }
return path;
}
/// <summary>Создать сборку из двух кубов 20³: A в нуле (world x[0,20]), B со сдвигом +100 (x[100,120]).</summary>
private async Task<string> SetupTwoCubesAsync()
{
var cube = await BuildAndSaveCubeAsync(20);
await _docs.CreateAsync(KompasDocumentType.Assembly);
await _assembly.AddComponentAsync(cube, 0, 0, 0);
await _assembly.AddComponentAsync(cube, 100, 0, 0);
return cube;
}
[Fact]
public async Task Coincidence_mate_brings_faces_flush()
{
await SetupTwoCubesAsync();
try
{
var bbBefore = await _query.GetBoundingBoxAsync();
Assert.InRange(bbBefore.MaxX - bbBefore.MinX, 118, 122); // 2 куба врозь ≈ 120
// Совпадение +X грани A (20,10,10) с X гранью B (100,10,10).
var m = await _assembly.AddMateAsync(MateType.Coincidence, 20, 10, 10, 100, 10, 10, value: 0);
Assert.True(m.Valid, "Сопряжение должно быть валидным.");
var bbAfter = await _query.GetBoundingBoxAsync();
Assert.InRange(bbAfter.MaxX - bbAfter.MinX, 38, 42); // кубы соприкоснулись ≈ 40
// Размеры самих компонентов не изменились (двигаются, не деформируются).
var comps = await _query.ListComponentsAsync();
Assert.Equal(2, comps.Count);
Assert.All(comps, c => Assert.InRange(c.SizeX, 19, 21));
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Distance_mate_holds_faces_apart()
{
await SetupTwoCubesAsync();
try
{
var bbBefore = await _query.GetBoundingBoxAsync();
Assert.InRange(bbBefore.MaxX - bbBefore.MinX, 118, 122);
// Расстояние 30 между +X гранью A и −X гранью B → ширина 20+30+20 = 70.
var m = await _assembly.AddMateAsync(MateType.Distance, 20, 10, 10, 100, 10, 10, value: 30);
Assert.True(m.Valid, "Сопряжение должно быть валидным.");
var bbAfter = await _query.GetBoundingBoxAsync();
Assert.InRange(bbAfter.MaxX - bbAfter.MinX, 66, 74); // ≈ 70
var comps = await _query.ListComponentsAsync();
Assert.All(comps, c => Assert.InRange(c.SizeX, 19, 21));
}
finally { await _docs.CloseAsync(save: false); }
}
}