From 5eee7ad1af340626635592dbbd4f66d2d7fa3fc0 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Wed, 27 May 2026 13:04:19 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20assembly=5Fadd=5Fcomponent=20=E2=80=94?= =?UTF-8?q?=20=D0=B2=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=B8=20=D0=B8=D0=B7=20.m3d=20=D0=B2=20?= =?UTF-8?q?=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D1=83=20(API7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Веха СБОРКИ, инкремент 1. Новый AssemblyService (API7, паттерн HoleService): IParts7.AddFromFile → Placement.SetOrigin → RebuildModel. Новый AssemblyTools (MCP-инструмент assembly_add_component), регистрация в DI. Ключевая находка: UpdatePlacement возвращает FALSE для вручную позиционируемого компонента (не ошибка), позицию применяет SetOrigin + RebuildModel(true). Валидация — чистый AssemblyValidation (расширение .m3d/.a3d, нормализация пути, конечность координат). RequireActiveAssembly — раздельные ошибки по типу документа. Тесты: +14 unit (AssemblyValidation), +3 integration (AssemblyTests: вставка в origin, позиционирование со сдвигом, отклонение не-сборки). Итого 83 unit + 53 integration = 136 зелёных. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-27-assembly-add-component-design.md | 14 ++- .../Assemblies/AddedComponent.cs | 11 ++ .../Assemblies/AssemblyService.cs | 94 +++++++++++++++ .../Assemblies/AssemblyValidation.cs | 38 ++++++ src/Kompas.Mcp.Host/Program.cs | 2 + src/Kompas.Mcp.Host/Tools/AssemblyTools.cs | 27 +++++ .../AssemblyValidationTests.cs | 44 +++++++ .../Integration/AssemblyTests.cs | 114 ++++++++++++++++++ 8 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 src/Kompas.Mcp.Core/Assemblies/AddedComponent.cs create mode 100644 src/Kompas.Mcp.Core/Assemblies/AssemblyService.cs create mode 100644 src/Kompas.Mcp.Core/Assemblies/AssemblyValidation.cs create mode 100644 src/Kompas.Mcp.Host/Tools/AssemblyTools.cs create mode 100644 tests/Kompas.Mcp.Tests/AssemblyValidationTests.cs create mode 100644 tests/Kompas.Mcp.Tests/Integration/AssemblyTests.cs diff --git a/docs/superpowers/specs/2026-05-27-assembly-add-component-design.md b/docs/superpowers/specs/2026-05-27-assembly-add-component-design.md index 58d3121..0f3c71a 100644 --- a/docs/superpowers/specs/2026-05-27-assembly-add-component-design.md +++ b/docs/superpowers/specs/2026-05-27-assembly-add-component-design.md @@ -1,7 +1,19 @@ # Дизайн: вставка компонента в сборку (assembly_add_component) через API7 **Дата:** 2026-05-27 -**Статус:** спроектировано (сигнатуры подтверждены рефлексией interop + kompas-sdk-research); ревью Codex учтено (8 замечаний внесены) +**Статус:** реализовано и проверено (3 интеграционных теста зелёные, 136 всего); ревью Codex спека учтено (8 замечаний) + +## Эмпирическая находка при реализации (важно — не разучивать) + +`part.UpdatePlacement(true)` возвращает **FALSE** при перемещении вручную позиционируемого +компонента (без сопряжений) — это **НЕ ошибка** (нечего пересчитывать от зависимостей). Само +перемещение задаёт `placement.SetOrigin(x,y,z)` (живой объект Placement, не копия — проверено: +сдвиг origin на +50 даёт габарит сборки X[50,90]), а **видимым в геометрии** оно становится после +`top.RebuildModel(true)`. Поэтому: результат `SetOrigin` проверяем (FALSE = ошибка), результат +`UpdatePlacement` **игнорируем**, обязателен `RebuildModel(true)`. Разфиксация первого компонента +(`Fixed=false`) **НЕ нужна** — зафиксированный КОМПАСом первый компонент всё равно перемещается +через `SetOrigin`+`RebuildModel` (проверено: оба теста — origin и offset — зелёные без снятия +фиксации). ## Цель diff --git a/src/Kompas.Mcp.Core/Assemblies/AddedComponent.cs b/src/Kompas.Mcp.Core/Assemblies/AddedComponent.cs new file mode 100644 index 0000000..208aacc --- /dev/null +++ b/src/Kompas.Mcp.Core/Assemblies/AddedComponent.cs @@ -0,0 +1,11 @@ +namespace Kompas.Mcp.Core.Assemblies; + +/// Результат вставки компонента в сборку. +public sealed record AddedComponent +{ + /// Индекс вставленного компонента в коллекции компонентов сборки. + public required int Index { get; init; } + + /// Имя вставленного компонента в дереве сборки. + public required string Name { get; init; } +} diff --git a/src/Kompas.Mcp.Core/Assemblies/AssemblyService.cs b/src/Kompas.Mcp.Core/Assemblies/AssemblyService.cs new file mode 100644 index 0000000..377fad6 --- /dev/null +++ b/src/Kompas.Mcp.Core/Assemblies/AssemblyService.cs @@ -0,0 +1,94 @@ +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Threading; +using Kompas6Constants; +using KompasAPI7; + +namespace Kompas.Mcp.Core.Assemblies; + +/// +/// Операции со сборками через API7 (IParts7). Аналогично HoleService/FaceEditService: +/// контейнеры берутся COM-QI, все вызовы — на STA-потоке . +/// +[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)); + } + + /// + /// Вставить компонент (деталь .m3d или подсборку .a3d) из файла в активную сборку, разместив + /// его origin в мировой точке (,,) мм. + /// + public Task 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)."); + + // ExternalFile=true: компонент ссылается на внешний .m3d/.a3d. Redraw=true: вставить и + // перерисовать в исходном положении (само перемещение применит UpdatePlacement ниже). + var part = parts.AddFromFile(path, true, true) as IPart7 + ?? 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); + top.RebuildModel(true); + + return new AddedComponent { Index = parts.Count - 1, Name = SafeName(part) }; + } + catch + { + // Best-effort откат осиротевшего компонента, если позиционирование не удалось. + try { part.Owner?.Delete(); } catch { /* лучшее усилие */ } + throw; + } + } + + /// + /// Получить верхний компонент активной сборки, проверив тип документа. Раздельные ошибки: + /// нет документа / документ не сборка / не 3D / нет TopPart. Деталь — тоже IKompasDocument3D, + /// поэтому отсекаем по DocumentType ДО доступа к TopPart. + /// + 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; } + } +} diff --git a/src/Kompas.Mcp.Core/Assemblies/AssemblyValidation.cs b/src/Kompas.Mcp.Core/Assemblies/AssemblyValidation.cs new file mode 100644 index 0000000..7bbafc3 --- /dev/null +++ b/src/Kompas.Mcp.Core/Assemblies/AssemblyValidation.cs @@ -0,0 +1,38 @@ +namespace Kompas.Mcp.Core.Assemblies; + +/// +/// Чистые валидаторы операций сборки (без COM) — выделены для unit-тестов. +/// +public static class AssemblyValidation +{ + /// Допустимые расширения файла-источника компонента: деталь (.m3d) и сборка (.a3d). + private static readonly HashSet AllowedExtensions = + new(StringComparer.OrdinalIgnoreCase) { ".m3d", ".a3d" }; + + /// + /// Нормализовать путь к файлу компонента: обрезать пробелы, проверить непустоту и расширение + /// (.m3d/.a3d, без учёта регистра), вернуть абсолютный путь. Существование файла НЕ проверяет + /// (это делает сервис на реальной ФС). + /// + public static string NormalizeComponentPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Путь к файлу компонента не задан.", nameof(path)); + + var trimmed = path.Trim(); + var ext = Path.GetExtension(trimmed); + if (!AllowedExtensions.Contains(ext)) + throw new ArgumentException( + $"Недопустимое расширение файла компонента '{ext}'. Ожидается .m3d (деталь) или .a3d (сборка).", + nameof(path)); + + return Path.GetFullPath(trimmed); + } + + /// Проверить, что координаты вставки конечны (отсекает 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)."); + } +} diff --git a/src/Kompas.Mcp.Host/Program.cs b/src/Kompas.Mcp.Host/Program.cs index dd7150d..7b17e3a 100644 --- a/src/Kompas.Mcp.Host/Program.cs +++ b/src/Kompas.Mcp.Host/Program.cs @@ -1,4 +1,5 @@ using Kompas.Mcp.Core; +using Kompas.Mcp.Core.Assemblies; using Kompas.Mcp.Core.Conversion; using Kompas.Mcp.Core.Documents; using Kompas.Mcp.Core.Editing; @@ -23,6 +24,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Kompas.Mcp.Host/Tools/AssemblyTools.cs b/src/Kompas.Mcp.Host/Tools/AssemblyTools.cs new file mode 100644 index 0000000..d192e99 --- /dev/null +++ b/src/Kompas.Mcp.Host/Tools/AssemblyTools.cs @@ -0,0 +1,27 @@ +using System.ComponentModel; +using Kompas.Mcp.Core; +using Kompas.Mcp.Core.Assemblies; +using ModelContextProtocol.Server; + +namespace Kompas.Mcp.Host.Tools; + +/// Инструменты работы со сборками (вставка/сопряжение компонентов). +[McpServerToolType] +public sealed class AssemblyTools(KompasSession session, AssemblyService assembly) +{ + [McpServerTool(Name = "assembly_add_component")] + [Description("Вставить компонент (деталь .m3d или подсборку .a3d) из файла в активную сборку. " + + "Активный документ должен быть сборкой (document_create assembly). origin компонента — точка (x,y,z) в мм " + + "мировой СК сборки (по умолчанию начало координат). Деталь-источник должна быть сохранена в файл " + + "(document_save_as). Возвращает индекс и имя вставленного компонента.")] + public async Task AddComponent( + [Description("Путь к файлу-источнику компонента (.m3d/.a3d)")] string filePath, + [Description("X положения origin компонента, мм")] double x = 0, + [Description("Y положения origin компонента, мм")] double y = 0, + [Description("Z положения origin компонента, мм")] double z = 0) + { + await session.ConnectAsync(); + var c = await assembly.AddComponentAsync(filePath, x, y, z); + return $"Компонент '{c.Name}' вставлен в сборку, индекс {c.Index}, позиция ({x}, {y}, {z})."; + } +} diff --git a/tests/Kompas.Mcp.Tests/AssemblyValidationTests.cs b/tests/Kompas.Mcp.Tests/AssemblyValidationTests.cs new file mode 100644 index 0000000..4c56243 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/AssemblyValidationTests.cs @@ -0,0 +1,44 @@ +using Kompas.Mcp.Core.Assemblies; + +namespace Kompas.Mcp.Tests; + +[Trait("Category", "Unit")] +public sealed class AssemblyValidationTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void NormalizeComponentPath_throws_on_empty(string? path) + => Assert.Throws(() => AssemblyValidation.NormalizeComponentPath(path)); + + [Theory] + [InlineData(@"C:\parts\bolt.step")] + [InlineData(@"C:\parts\bolt.txt")] + [InlineData(@"C:\parts\bolt")] + public void NormalizeComponentPath_throws_on_wrong_extension(string path) + => Assert.Throws(() => AssemblyValidation.NormalizeComponentPath(path)); + + [Theory] + [InlineData(@"C:\parts\bolt.m3d", ".m3d")] + [InlineData(@"C:\parts\frame.a3d", ".a3d")] + [InlineData(@"C:\parts\bolt.M3D", ".m3d")] // регистр не важен + [InlineData(" C:\\parts\\bolt.a3d ", ".a3d")] // пробелы обрезаются + public void NormalizeComponentPath_accepts_m3d_and_a3d(string path, string expectedExt) + { + var result = AssemblyValidation.NormalizeComponentPath(path); + Assert.True(Path.IsPathFullyQualified(result), "Должен вернуться абсолютный путь."); + Assert.EndsWith(expectedExt, result, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(double.NaN, 0, 0)] + [InlineData(0, double.PositiveInfinity, 0)] + [InlineData(0, 0, double.NegativeInfinity)] + public void RequireFinitePosition_throws_on_non_finite(double x, double y, double z) + => Assert.Throws(() => AssemblyValidation.RequireFinitePosition(x, y, z)); + + [Fact] + public void RequireFinitePosition_accepts_finite() + => Assert.Null(Record.Exception(() => AssemblyValidation.RequireFinitePosition(50, -10, 0))); +} diff --git a/tests/Kompas.Mcp.Tests/Integration/AssemblyTests.cs b/tests/Kompas.Mcp.Tests/Integration/AssemblyTests.cs new file mode 100644 index 0000000..694a68b --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/AssemblyTests.cs @@ -0,0 +1,114 @@ +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 IParts7.AddFromFile + Placement). +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class AssemblyTests +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly AssemblyService _assembly; + private readonly QueryService _query; + + public AssemblyTests(KompasFixture 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); + } + + /// Построить деталь-коробку от нуля (габарит X[0,w] Y[0,d] Z[0,h]), сохранить в .m3d, + /// проверить файл, закрыть. Возвращает абсолютный путь к сохранённому файлу. + private async Task BuildAndSaveBoxAsync(double w, double d, double h) + { + var path = TestPaths.NewFile(".m3d"); + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, 0, 0, w, d); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: h); + await _modeler.RebuildAsync(); + await _docs.SaveAsAsync(path); + } + finally { await _docs.CloseAsync(save: false); } + + // Отделяем проблемы сохранения от проблем вставки: файл должен реально записаться. + Assert.True(File.Exists(path), $"Файл детали не сохранён: {path}"); + Assert.True(new FileInfo(path).Length > 0, "Файл детали пуст."); + return path; + } + + [Fact] + public async Task AddComponent_inserts_one_component_at_origin() + { + var partFile = await BuildAndSaveBoxAsync(40, 40, 20); + + await _docs.CreateAsync(KompasDocumentType.Assembly); + try + { + var before = (await _query.ListComponentsAsync()).Count; + + var added = await _assembly.AddComponentAsync(partFile, 0, 0, 0); + + var comps = await _query.ListComponentsAsync(); + Assert.Equal(before + 1, comps.Count); + Assert.False(string.IsNullOrWhiteSpace(added.Name), "Имя вставленного компонента не должно быть пустым."); + + var c = comps[added.Index]; + Assert.InRange(c.SizeX, 40 * 0.95, 40 * 1.05); + Assert.InRange(c.SizeY, 40 * 0.95, 40 * 1.05); + Assert.InRange(c.SizeZ, 20 * 0.95, 20 * 1.05); + + // Габарит сборки совпадает с деталью, вставленной в начало координат. + var bb = await _query.GetBoundingBoxAsync(); + Assert.InRange(bb.MinX, -1, 1); + Assert.InRange(bb.MaxX, 39, 41); + Assert.InRange(bb.MinY, -1, 1); + Assert.InRange(bb.MaxY, 39, 41); + Assert.InRange(bb.MinZ, -1, 1); + Assert.InRange(bb.MaxZ, 19, 21); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task AddComponent_positions_component_at_offset() + { + var partFile = await BuildAndSaveBoxAsync(40, 40, 20); + + await _docs.CreateAsync(KompasDocumentType.Assembly); + try + { + // Вставка со сдвигом origin на +50 по X → габарит сборки X[50,90]. + await _assembly.AddComponentAsync(partFile, 50, 0, 0); + + var bb = await _query.GetBoundingBoxAsync(); + Assert.InRange(bb.MinX, 49, 51); + Assert.InRange(bb.MaxX, 89, 91); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task AddComponent_rejects_non_assembly_document() + { + // Активный документ — деталь (не сборка): должна быть понятная ошибка. + var partFile = await BuildAndSaveBoxAsync(20, 20, 10); + + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + await Assert.ThrowsAsync( + () => _assembly.AddComponentAsync(partFile, 0, 0, 0)); + } + finally { await _docs.CloseAsync(save: false); } + } +}