From 742a97177b2d77fba091eb2ce83bd9d2f5dd2475 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Wed, 27 May 2026 17:20:16 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20drawing=5Ffill=5Ftitle=5Fblock=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B4=D0=BF=D0=B8=D1=81=D1=8C=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D1=82=D0=B5=D0=B6=D0=B0=20(API7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Веха 2D-ЧЕРТЁЖ, инкремент 2 (оформление). DrawingService.FillTitleBlockAsync: LayoutSheets.ItemByNumber[1].Stamp.Text[id].Str = text → Update(). Enum StampField → номер графы ГОСТ (designation=2, name=1, material=3). Заполнение нескольких граф за вызов; перезапись (Str замещает, Clear не нужен — подтверждено спайком). Новый инструмент drawing_fill_title_block; ReadStampCellAsync для проверки. Ревью Codex спека учтено: Update() результат проверяется; null-checks (LayoutSheets/sheet/Stamp); RequireActiveDrawing выделен (сохраняет cast-проверку); ColumnId switch с дефолтным throw; счётчик = непустые поля; name=графа 1 однозначно. Валидация — чистый StampFields (ColumnId + Collect). Тесты: +7 unit (StampFields), +4 integration (DrawingStampTests: заполнение+reread 3 граф, перезапись, пустой ввод, не-чертёж). Итого 123 unit + 65 integration = 188. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-27-drawing-title-block-design.md | 25 +++++- .../Drawings/DrawingService.cs | 58 +++++++++++-- src/Kompas.Mcp.Core/Drawings/StampField.cs | 47 ++++++++++ src/Kompas.Mcp.Host/Tools/DrawingTools.cs | 15 ++++ .../Integration/DrawingStampTests.cs | 85 +++++++++++++++++++ tests/Kompas.Mcp.Tests/StampFieldsTests.cs | 41 +++++++++ 6 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 src/Kompas.Mcp.Core/Drawings/StampField.cs create mode 100644 tests/Kompas.Mcp.Tests/Integration/DrawingStampTests.cs create mode 100644 tests/Kompas.Mcp.Tests/StampFieldsTests.cs diff --git a/docs/superpowers/specs/2026-05-27-drawing-title-block-design.md b/docs/superpowers/specs/2026-05-27-drawing-title-block-design.md index a4addc6..ef10d9e 100644 --- a/docs/superpowers/specs/2026-05-27-drawing-title-block-design.md +++ b/docs/superpowers/specs/2026-05-27-drawing-title-block-design.md @@ -1,7 +1,30 @@ # Дизайн: основная надпись чертежа (drawing_fill_title_block) через API7 **Дата:** 2026-05-27 -**Статус:** спайк зелёный (round-trip графы подтверждён); спроектировано; на ревью (Codex) +**Статус:** реализовано и проверено (4 интеграционных + 7 unit, всего 188); ревью Codex спека учтено + +## Находка спайка: `Str` ПЕРЕЗАПИСЫВАЕТ (Clear не нужен) + +Двойная запись в графу 2 («ДЕТАЛЬ.001» → «ИЗДЕЛИЕ.002») → чтение даёт «ИЗДЕЛИЕ.002» (не +дописано). Значит `stamp.Text[id].Str = value` **замещает** содержимое — `IText.Clear()` перед +записью НЕ нужен. + +## Правки по ревью Codex + +- **Update() результат проверять**: `if (!stamp.Update()) throw`. +- **null-checks**: `LayoutSheets`, `sheet`, `sheet.Stamp` (ранняя диагностируемая ошибка). +- **`RequireActiveDrawing()`** сохраняет проверку `doc is not IKompasDocument2D` (не только + `DocumentType`); `RequireActiveDrawingViews` становится надстройкой над ним. +- **`ColumnId` switch** — дефолт `ArgumentOutOfRangeException`; unit-тест на `(StampField)999`. +- **Тест**: писать и перечитывать ВСЕ три графы (вкл. `name`=графа 1); явный reread `Text[id].Str`; + тест overwrite (запись дважды → читается второе) — уже подтверждён спайком, перенесём в регрессию. +- **`name` = «Наименование изделия» (графа 1, `ksStPartNumber`)** — однозначно в описании (не путать + с `ksStDocumentName=51`). +- **Счётчик результата** = число непустых (trimmed) полей, записанных до успешного `Update()`. +- **Сигнатура**: `FillTitleBlockAsync(string? designation = null, string? name = null, + string? material = null, ct)`. +- **Scale (графа 6) и прочие — ЯВНО вне scope инкремента 2.** Spike-метод и spike-тест удаляются. +- RCW точечно не освобождаем (v2-2). ## Цель diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingService.cs b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs index ea94d5a..a0b3d9f 100644 --- a/src/Kompas.Mcp.Core/Drawings/DrawingService.cs +++ b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs @@ -30,6 +30,48 @@ public sealed class DrawingService double x, double y, CancellationToken ct = default) => _dispatcher.InvokeAsync(() => CreateStandardViewsCore(partFilePath, scale, x, y), ct); + /// + /// Заполнить графы основной надписи активного чертежа: обозначение (графа 2), наименование + /// изделия (графа 1), материал (графа 3). Передаются только нужные поля (null/пусто — + /// пропускается). Возвращает число заполненных граф. + /// + public Task FillTitleBlockAsync(string? designation = null, string? name = null, + string? material = null, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => FillTitleBlockCore(designation, name, material), ct); + + private int FillTitleBlockCore(string? designation, string? name, string? material) + { + var fields = StampFields.Collect(designation, name, material); + if (fields.Count == 0) + throw new ArgumentException("Укажите хотя бы одно поле основной надписи (designation/name/material)."); + + var stamp = RequireActiveStamp(); + foreach (var (columnId, text) in fields) + stamp.Text[columnId].Str = text; // Str замещает содержимое (Clear не нужен) + if (!stamp.Update()) + throw new InvalidOperationException("IStamp.Update вернул FALSE — основная надпись не обновлена."); + return fields.Count; + } + + /// Прочитать текст графы основной надписи (для проверки/диагностики). + public Task ReadStampCellAsync(int columnId, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var stamp = RequireActiveStamp(); + return stamp.Text[columnId]?.Str ?? string.Empty; + }, ct); + + private IStamp RequireActiveStamp() + { + var doc2d = RequireActiveDrawing(); + var sheets = doc2d.LayoutSheets + ?? throw new InvalidOperationException("Не удалось получить листы оформления чертежа."); + var sheet = sheets.ItemByNumber[1] as ILayoutSheet + ?? throw new InvalidOperationException("На чертеже нет листа оформления #1."); + return sheet.Stamp as IStamp + ?? throw new InvalidOperationException("У листа оформления нет основной надписи (Stamp)."); + } + // Стандартная тройка проекций: ksPtFront=1, ksPtUp=3, ksPtLeft=5 (ksRelativeProjectionTypeEnum). private static readonly object[] StandardProjections = { 1, 3, 5 }; private const int ExpectedCreated = 3; @@ -79,19 +121,25 @@ public sealed class DrawingService } } - private IViews RequireActiveDrawingViews() + /// + /// Получить активный чертёж как . Раздельные ошибки: нет документа / + /// тип != ksDocumentDrawing / не приводится к IKompasDocument2D. + /// + private IKompasDocument2D RequireActiveDrawing() { var doc = _session.Application.ActiveDocument ?? throw new InvalidOperationException("Нет активного документа. Создайте чертёж: document_create drawing."); if (doc.DocumentType != DocumentTypeEnum.ksDocumentDrawing) throw new InvalidOperationException( $"Активный документ не является чертежом (тип: {doc.DocumentType}). Создайте чертёж: document_create drawing."); - if (doc is not IKompasDocument2D doc2d) - throw new InvalidOperationException("Активный документ не приводится к IKompasDocument2D."); - return doc2d.ViewsAndLayersManager?.Views - ?? throw new InvalidOperationException("Не удалось получить коллекцию видов чертежа."); + return doc as IKompasDocument2D + ?? throw new InvalidOperationException("Активный документ не приводится к IKompasDocument2D."); } + private IViews RequireActiveDrawingViews() + => RequireActiveDrawing().ViewsAndLayersManager?.Views + ?? throw new InvalidOperationException("Не удалось получить коллекцию видов чертежа."); + private static HashSet CollectViewNumbers(IViews views) { var set = new HashSet(); diff --git a/src/Kompas.Mcp.Core/Drawings/StampField.cs b/src/Kompas.Mcp.Core/Drawings/StampField.cs new file mode 100644 index 0000000..6b92ce0 --- /dev/null +++ b/src/Kompas.Mcp.Core/Drawings/StampField.cs @@ -0,0 +1,47 @@ +namespace Kompas.Mcp.Core.Drawings; + +/// +/// Поле основной надписи (графа штампа ГОСТ). Маппится на номер графы ksStampEnum. +/// Пока поддержаны три ключевые графы; остальные (масштаб, разработал, даты…) — позже. +/// +public enum StampField +{ + /// Обозначение документа (графа 2, ksStDescription). + Designation, + /// Наименование изделия (графа 1, ksStPartNumber). + Name, + /// Материал (графа 3, ksStMaterial). + Material, +} + +/// Маппинг поля основной надписи → номер графы и сбор непустых полей. +public static class StampFields +{ + /// Номер графы (ksStampEnum) для поля основной надписи. + public static int ColumnId(StampField field) => field switch + { + StampField.Designation => 2, // ksStDescription + StampField.Name => 1, // ksStPartNumber (наименование изделия) + StampField.Material => 3, // ksStMaterial + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестное поле основной надписи."), + }; + + /// + /// Собрать непустые (после Trim) поля в список (номер графы, текст). null/пусто/пробелы — + /// пропускаются. Чистая (без COM) — unit-тестируемо. + /// + public static IReadOnlyList<(int ColumnId, string Text)> Collect(string? designation, string? name, string? material) + { + var list = new List<(int, string)>(3); + Add(list, StampField.Designation, designation); + Add(list, StampField.Name, name); + Add(list, StampField.Material, material); + return list; + } + + private static void Add(List<(int, string)> list, StampField field, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + list.Add((ColumnId(field), value.Trim())); + } +} diff --git a/src/Kompas.Mcp.Host/Tools/DrawingTools.cs b/src/Kompas.Mcp.Host/Tools/DrawingTools.cs index d96ab9b..9ca2ad8 100644 --- a/src/Kompas.Mcp.Host/Tools/DrawingTools.cs +++ b/src/Kompas.Mcp.Host/Tools/DrawingTools.cs @@ -26,4 +26,19 @@ public sealed class DrawingTools(KompasSession session, DrawingService drawing) var r = await drawing.CreateStandardViewsAsync(partFilePath, scale, x, y); return $"Создано {r.Created} стандартных вида на чертеже (всего видов: {r.Total}, масштаб {scale})."; } + + [McpServerTool(Name = "drawing_fill_title_block")] + [Description("Заполнить графы основной надписи (штампа) активного чертежа. Передавайте только нужные поля: " + + "designation — обозначение документа (графа 2); name — наименование изделия (графа 1); " + + "material — материал (графа 3). Незаданные/пустые поля пропускаются (нужно хотя бы одно). " + + "Существующий текст графы перезаписывается. Возвращает число заполненных граф.")] + public async Task FillTitleBlock( + [Description("Обозначение документа (графа 2)")] string? designation = null, + [Description("Наименование изделия (графа 1)")] string? name = null, + [Description("Материал (графа 3)")] string? material = null) + { + await session.ConnectAsync(); + var filled = await drawing.FillTitleBlockAsync(designation, name, material); + return $"Заполнено граф основной надписи: {filled}."; + } } diff --git a/tests/Kompas.Mcp.Tests/Integration/DrawingStampTests.cs b/tests/Kompas.Mcp.Tests/Integration/DrawingStampTests.cs new file mode 100644 index 0000000..005102e --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/DrawingStampTests.cs @@ -0,0 +1,85 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Drawings; +using Kompas.Mcp.Core.Modeling; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: заполнение основной надписи чертежа (API7 IStamp.Text). +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class DrawingStampTests : IntegrationTestBase +{ + private readonly DocumentService _docs; + private readonly DrawingService _drawing; + private readonly PartModeler _modeler; + + public DrawingStampTests(KompasFixture fx) : base(fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _drawing = new DrawingService(fx.Session, fx.Dispatcher); + _modeler = new PartModeler(fx.Session, fx.Dispatcher); + } + + [Fact] + public async Task Fills_and_reads_back_title_block_cells() + { + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + var filled = await _drawing.FillTitleBlockAsync( + designation: "ДЕТАЛЬ.001", name: "Кронштейн", material: "Сталь 45"); + Assert.Equal(3, filled); + + // Явный reread каждой графы (графа 2/1/3). + Assert.Equal("ДЕТАЛЬ.001", await _drawing.ReadStampCellAsync(2)); + Assert.Equal("Кронштейн", await _drawing.ReadStampCellAsync(1)); + Assert.Equal("Сталь 45", await _drawing.ReadStampCellAsync(3)); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Overwrites_existing_cell_text() + { + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + await _drawing.FillTitleBlockAsync(designation: "СТАРОЕ.000"); + await _drawing.FillTitleBlockAsync(designation: "НОВОЕ.111"); + + // Перезапись, не дописывание. + Assert.Equal("НОВОЕ.111", await _drawing.ReadStampCellAsync(2)); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Empty_input_throws() + { + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + await Assert.ThrowsAsync( + () => _drawing.FillTitleBlockAsync(null, " ", null)); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Rejects_non_drawing_document() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Деталь — не чертёж: FillTitleBlock должен бросить. + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, 0, 0, 10, 10); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: 5); + + await Assert.ThrowsAsync( + () => _drawing.FillTitleBlockAsync(designation: "X")); + } + finally { await _docs.CloseAsync(save: false); } + } +} diff --git a/tests/Kompas.Mcp.Tests/StampFieldsTests.cs b/tests/Kompas.Mcp.Tests/StampFieldsTests.cs new file mode 100644 index 0000000..90ef195 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/StampFieldsTests.cs @@ -0,0 +1,41 @@ +using Kompas.Mcp.Core.Drawings; + +namespace Kompas.Mcp.Tests; + +[Trait("Category", "Unit")] +public sealed class StampFieldsTests +{ + [Theory] + [InlineData(StampField.Designation, 2)] + [InlineData(StampField.Name, 1)] + [InlineData(StampField.Material, 3)] + public void ColumnId_maps_known_fields(StampField field, int expected) + => Assert.Equal(expected, StampFields.ColumnId(field)); + + [Fact] + public void ColumnId_throws_on_unknown() + => Assert.Throws(() => StampFields.ColumnId((StampField)999)); + + [Fact] + public void Collect_skips_null_empty_and_whitespace() + => Assert.Empty(StampFields.Collect(null, "", " ")); + + [Fact] + public void Collect_trims_and_maps_provided_fields() + { + var result = StampFields.Collect(" ДЕТАЛЬ.001 ", null, "Сталь 45"); + + Assert.Equal(2, result.Count); + Assert.Contains(result, t => t.ColumnId == 2 && t.Text == "ДЕТАЛЬ.001"); // designation + Assert.Contains(result, t => t.ColumnId == 3 && t.Text == "Сталь 45"); // material + Assert.DoesNotContain(result, t => t.ColumnId == 1); // name не задан + } + + [Fact] + public void Collect_includes_name_as_column_1() + { + var result = StampFields.Collect(null, "Кронштейн", null); + Assert.Single(result); + Assert.Equal((1, "Кронштейн"), result[0]); + } +}