diff --git a/docs/superpowers/specs/2026-05-27-assembly-add-mate-design.md b/docs/superpowers/specs/2026-05-27-assembly-add-mate-design.md
index b6f4a70..b0b8ea3 100644
--- a/docs/superpowers/specs/2026-05-27-assembly-add-mate-design.md
+++ b/docs/superpowers/specs/2026-05-27-assembly-add-mate-design.md
@@ -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); в описании
+ инструмента — «точка в СЕРЕДИНЕ грани» (на ребре/углу хит неоднозначен).
## Цель
diff --git a/src/Kompas.Mcp.Core/Assemblies/AddedMate.cs b/src/Kompas.Mcp.Core/Assemblies/AddedMate.cs
new file mode 100644
index 0000000..e9e35b5
--- /dev/null
+++ b/src/Kompas.Mcp.Core/Assemblies/AddedMate.cs
@@ -0,0 +1,11 @@
+namespace Kompas.Mcp.Core.Assemblies;
+
+/// Результат наложения сопряжения между компонентами сборки.
+public sealed record AddedMate
+{
+ /// Тип наложенного сопряжения (как был запрошен).
+ public required MateType Type { get; init; }
+
+ /// true — сопряжение успешно построено и не вырождено после перестроения.
+ public required bool Valid { get; init; }
+}
diff --git a/src/Kompas.Mcp.Core/Assemblies/AssemblyService.cs b/src/Kompas.Mcp.Core/Assemblies/AssemblyService.cs
index 5393bcb..197855b 100644
--- a/src/Kompas.Mcp.Core/Assemblies/AssemblyService.cs
+++ b/src/Kompas.Mcp.Core/Assemblies/AssemblyService.cs
@@ -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
}
}
+ ///
+ /// Наложить сопряжение между гранями двух компонентов активной сборки,
+ /// выбранными по мировым точкам (,,)
+ /// и (,,). Для расстояния
+ /// — зазор (мм). Решатель спозиционирует компоненты; результат — статус
+ /// построенного сопряжения.
+ ///
+ public Task 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;
+ }
+ }
+
///
/// Откатить вставку: удалить компоненты, появившиеся сверх (обычно
/// один — только что вставленный). Best-effort, но с верификацией: компонент удаляется через
diff --git a/src/Kompas.Mcp.Core/Assemblies/AssemblyValidation.cs b/src/Kompas.Mcp.Core/Assemblies/AssemblyValidation.cs
index 7bbafc3..68c44c7 100644
--- a/src/Kompas.Mcp.Core/Assemblies/AssemblyValidation.cs
+++ b/src/Kompas.Mcp.Core/Assemblies/AssemblyValidation.cs
@@ -29,10 +29,22 @@ public static class AssemblyValidation
return Path.GetFullPath(trimmed);
}
- /// Проверить, что координаты вставки конечны (отсекает NaN/Infinity).
+ /// Проверить, что координаты конечны (отсекает NaN/Infinity).
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).");
+ }
+
+ ///
+ /// Проверить числовое значение сопряжения. Если значение требуется (,
+ /// напр. расстояние) — оно должно быть конечным и неотрицательным.
+ ///
+ public static void RequireMateValue(bool requiresValue, double value)
+ {
+ if (!requiresValue) return; // для типов без параметра значение игнорируется
+ if (!double.IsFinite(value) || value < 0)
+ throw new ArgumentOutOfRangeException(nameof(value),
+ "Значение сопряжения (расстояние) должно быть конечным и неотрицательным.");
}
}
diff --git a/src/Kompas.Mcp.Core/Assemblies/MateType.cs b/src/Kompas.Mcp.Core/Assemblies/MateType.cs
new file mode 100644
index 0000000..7dbe02e
--- /dev/null
+++ b/src/Kompas.Mcp.Core/Assemblies/MateType.cs
@@ -0,0 +1,44 @@
+using Kompas6Constants3D;
+
+namespace Kompas.Mcp.Core.Assemblies;
+
+///
+/// Дружелюбный тип сопряжения (обёртка над ). Пока поддержаны только
+/// геометрически проверенные типы — совпадение и расстояние; остальные (параллельность, соосность,
+/// угол и т.д.) добавятся в следующих инкрементах вместе с тестами.
+///
+public enum MateType
+{
+ /// Совпадение (грани лежат на одной плоскости, заподлицо).
+ Coincidence,
+ /// На расстоянии (постоянный зазор между гранями, мм).
+ Distance,
+}
+
+/// Маппинг дружелюбного типа сопряжения ↔ перечисление API КОМПАС и разбор строки.
+public static class Mates
+{
+ /// Сопряжения, которым нужно числовое значение (ParamValue): расстояние.
+ 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, "Неизвестный тип сопряжения."),
+ };
+
+ /// Разобрать строку типа сопряжения (англ./рус.) в .
+ 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)),
+ };
+ }
+}
diff --git a/src/Kompas.Mcp.Host/Tools/AssemblyTools.cs b/src/Kompas.Mcp.Host/Tools/AssemblyTools.cs
index d192e99..c6f7477 100644
--- a/src/Kompas.Mcp.Host/Tools/AssemblyTools.cs
+++ b/src/Kompas.Mcp.Host/Tools/AssemblyTools.cs
@@ -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 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}).";
+ }
}
diff --git a/tests/Kompas.Mcp.Tests/AssemblyValidationTests.cs b/tests/Kompas.Mcp.Tests/AssemblyValidationTests.cs
index 4c56243..b646961 100644
--- a/tests/Kompas.Mcp.Tests/AssemblyValidationTests.cs
+++ b/tests/Kompas.Mcp.Tests/AssemblyValidationTests.cs
@@ -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(() => 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(() => AssemblyValidation.RequireMateValue(true, value));
+
+ [Fact]
+ public void RequireMateValue_accepts_nonnegative_required_value()
+ => Assert.Null(Record.Exception(() => AssemblyValidation.RequireMateValue(true, 30)));
}
diff --git a/tests/Kompas.Mcp.Tests/Integration/AssemblyMateTests.cs b/tests/Kompas.Mcp.Tests/Integration/AssemblyMateTests.cs
new file mode 100644
index 0000000..d80a89f
--- /dev/null
+++ b/tests/Kompas.Mcp.Tests/Integration/AssemblyMateTests.cs
@@ -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;
+
+/// Интеграция: сопряжения компонентов сборки (API7 IMateConstraints3D).
+[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 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;
+ }
+
+ /// Создать сборку из двух кубов 20³: A в нуле (world x[0,20]), B со сдвигом +100 (x[100,120]).
+ private async Task 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); }
+ }
+}