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); } + } +}