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