diff --git a/docs/superpowers/specs/2026-05-27-drawing-linear-dimension-design.md b/docs/superpowers/specs/2026-05-27-drawing-linear-dimension-design.md
index 984462d..29171eb 100644
--- a/docs/superpowers/specs/2026-05-27-drawing-linear-dimension-design.md
+++ b/docs/superpowers/specs/2026-05-27-drawing-linear-dimension-design.md
@@ -1,7 +1,29 @@
# Дизайн: линейный размер на виде чертежа (drawing_add_linear_dimension) через API7
**Дата:** 2026-05-27
-**Статус:** спайк зелёный (механизм + авто-значение подтверждены); спроектировано; на ревью (Codex)
+**Статус:** реализовано и проверено (5 интеграционных + unit, всего 201); ревью Codex спека учтено
+
+## Находка реализации: размеры НЕ в IView.ObjectCount
+
+`IView.ObjectCount` считает **геометрию** (`IDrawingContainer`: линии/дуги), а размеры живут в
+`ISymbols2DContainer` отдельно → после простановки размера `ObjectCount` НЕ меняется (проверено: 4→4).
+Поэтому «размер попал в целевой вид» проверяем счётом `ISymbols2DContainer.LineDimensions.Count`
+(helper `GetViewLineDimensionCountAsync`), а не `ObjectCount`. (Гарантия непустоты у стандартных видов
+по `ObjectCount` остаётся верной — там именно геометрия.)
+
+## Правки по ревью Codex
+
+- **Нулевая длина** (#1): `DrawingValidation.RequireDistinctPoints(x1,y1,x2,y2)` — расстояние между
+ точками > 1e-7 (иначе вырожденный размер); + проверка `dim.Valid` после `Update()` (откат);
+ + негативный тест.
+- **Контракт вида** (#4): честно — «первый НЕСИСТЕМНЫЙ вид (Number != 0)» при `viewNumber<=0`, без
+ претензии на ассоциативность и без QI к `IAssociationView` (в наших чертежах иных видов нет).
+- **null-check + порядок** (#9): `LineDimensions.Add()` проверять на null; `AutoNominalValue=true`
+ выставлять явно; `NominalValue` читать ТОЛЬКО после `Update()==true && Valid==true`.
+- **ViewNumbers** (#7): поле `required` в `DrawingViewsResult`; инициализатор и текст MCP-ответа
+ `drawing_create_standard_views` обновить (вернуть номера видов).
+- **Тесты** (#6): ObjectCount ЦЕЛЕВОГО вида до/после; адресация конкретного `viewNumber`; негатив на
+ совпадающие точки. **viewNumber не найден** (#2) → понятная ошибка. 3D-конвертер точек (#8) — будущее.
## Цель
diff --git a/src/Kompas.Mcp.Core/Drawings/DimensionOrientation.cs b/src/Kompas.Mcp.Core/Drawings/DimensionOrientation.cs
new file mode 100644
index 0000000..29e6e8f
--- /dev/null
+++ b/src/Kompas.Mcp.Core/Drawings/DimensionOrientation.cs
@@ -0,0 +1,41 @@
+using Kompas6Constants;
+
+namespace Kompas.Mcp.Core.Drawings;
+
+/// Ориентация линейного размера (обёртка над ).
+public enum DimensionOrientation
+{
+ /// Горизонтальный (измеряет |X2−X1|).
+ Horizontal,
+ /// Вертикальный (измеряет |Y2−Y1|).
+ Vertical,
+ /// Параллельный отрезку P1→P2 (реальная длина).
+ Parallel,
+}
+
+/// Маппинг ориентации размера ↔ перечисление КОМПАС и разбор строки.
+public static class DimensionOrientations
+{
+ public static ksLineDimensionOrientationEnum ToKompas(DimensionOrientation orientation) => orientation switch
+ {
+ DimensionOrientation.Horizontal => ksLineDimensionOrientationEnum.ksLinDHorizontal,
+ DimensionOrientation.Vertical => ksLineDimensionOrientationEnum.ksLinDVertical,
+ DimensionOrientation.Parallel => ksLineDimensionOrientationEnum.ksLinDParallel,
+ _ => throw new ArgumentOutOfRangeException(nameof(orientation), orientation, "Неизвестная ориентация размера."),
+ };
+
+ /// Разобрать строку ориентации (англ./рус.) в .
+ public static DimensionOrientation Parse(string value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ return value.Trim().ToLowerInvariant() switch
+ {
+ "horizontal" or "h" or "горизонтальный" or "горизонтальная" => DimensionOrientation.Horizontal,
+ "vertical" or "v" or "вертикальный" or "вертикальная" => DimensionOrientation.Vertical,
+ "parallel" or "p" or "параллельный" or "параллельная" => DimensionOrientation.Parallel,
+ _ => throw new ArgumentException(
+ $"Неизвестная ориентация размера '{value}'. Допустимо: horizontal, vertical, parallel.",
+ nameof(value)),
+ };
+ }
+}
diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingDimensionResult.cs b/src/Kompas.Mcp.Core/Drawings/DrawingDimensionResult.cs
new file mode 100644
index 0000000..3ccc082
--- /dev/null
+++ b/src/Kompas.Mcp.Core/Drawings/DrawingDimensionResult.cs
@@ -0,0 +1,11 @@
+namespace Kompas.Mcp.Core.Drawings;
+
+/// Результат простановки размера на чертеже.
+public sealed record DrawingDimensionResult
+{
+ /// Измеренное значение размера (мм).
+ public required double Value { get; init; }
+
+ /// Номер вида, на котором поставлен размер.
+ public required int ViewNumber { get; init; }
+}
diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingService.cs b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs
index 7a2d6b8..2e39f68 100644
--- a/src/Kompas.Mcp.Core/Drawings/DrawingService.cs
+++ b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs
@@ -84,6 +84,79 @@ public sealed class DrawingService
?? throw new InvalidOperationException("У листа оформления нет основной надписи (Stamp).");
}
+ ///
+ /// Поставить линейный размер на виде активного чертежа между точками (x1,y1)-(x2,y2) в ЛОКАЛЬНОЙ
+ /// СК вида (мм); размерная линия проходит через (x3,y3). — номер вида
+ /// (≤0 = первый несистемный/главный). Значение измеряется автоматически. Возвращает значение и номер вида.
+ ///
+ public Task AddLinearDimensionAsync(int viewNumber, double x1, double y1,
+ double x2, double y2, double x3, double y3, DimensionOrientation orientation, CancellationToken ct = default)
+ => _dispatcher.InvokeAsync(() =>
+ AddLinearDimensionCore(viewNumber, x1, y1, x2, y2, x3, y3, orientation), ct);
+
+ private DrawingDimensionResult AddLinearDimensionCore(int viewNumber, double x1, double y1,
+ double x2, double y2, double x3, double y3, DimensionOrientation orientation)
+ {
+ DrawingValidation.RequireFiniteCoords(x1, y1, x2, y2, x3, y3);
+ DrawingValidation.RequireDistinctPoints(x1, y1, x2, y2);
+
+ var view = FindView(viewNumber);
+ var symbols = view as ISymbols2DContainer
+ ?? throw new InvalidOperationException("Вид не приводится к ISymbols2DContainer.");
+ var dim = symbols.LineDimensions?.Add()
+ ?? throw new InvalidOperationException("LineDimensions.Add вернул null.");
+ try
+ {
+ dim.X1 = x1; dim.Y1 = y1; dim.X2 = x2; dim.Y2 = y2; dim.X3 = x3; dim.Y3 = y3;
+ dim.Orientation = DimensionOrientations.ToKompas(orientation);
+ ((IDimensionText)dim).AutoNominalValue = true; // авто-измерение значения (по умолчанию, явно)
+
+ if (!dim.Update())
+ throw new InvalidOperationException("ILineDimension.Update вернул FALSE — размер не построен.");
+ if (!dim.Valid)
+ throw new InvalidOperationException("Размер построен невалидным (проверьте точки/ориентацию).");
+
+ // NominalValue читаем ТОЛЬКО после успешного Update и Valid.
+ var value = ((IDimensionText)dim).NominalValue;
+ return new DrawingDimensionResult { Value = value, ViewNumber = view.Number };
+ }
+ catch
+ {
+ try { dim.Delete(); } catch { /* лучшее усилие */ }
+ throw;
+ }
+ }
+
+ /// Число линейных размеров на виде по номеру (для проверки/диагностики). Размеры живут
+ /// в ISymbols2DContainer вида, а НЕ в IView.ObjectCount (тот считает геометрию).
+ public Task GetViewLineDimensionCountAsync(int viewNumber, CancellationToken ct = default)
+ => _dispatcher.InvokeAsync(() =>
+ {
+ var symbols = (ISymbols2DContainer)FindView(viewNumber);
+ return symbols.LineDimensions?.Count ?? 0;
+ }, ct);
+
+ /// Найти вид: при viewNumber ≤ 0 — первый несистемный (Number != 0); иначе по номеру.
+ private IView FindView(int viewNumber)
+ {
+ var views = RequireActiveDrawingViews();
+ foreach (var o in views)
+ {
+ if (o is not IView v) continue;
+ if (viewNumber <= 0)
+ {
+ if (v.Number != 0) return v; // первый несистемный
+ }
+ else if (v.Number == viewNumber)
+ {
+ return v;
+ }
+ }
+ throw new InvalidOperationException(viewNumber <= 0
+ ? "На чертеже нет несистемного вида. Сначала создайте виды: drawing_create_standard_views."
+ : $"Вид с номером {viewNumber} не найден на чертеже.");
+ }
+
// Стандартная тройка проекций: ksPtFront=1, ksPtUp=3, ksPtLeft=5 (ksRelativeProjectionTypeEnum).
private static readonly object[] StandardProjections = { 1, 3, 5 };
private const int ExpectedCreated = 3;
@@ -122,7 +195,12 @@ public sealed class DrawingService
throw new InvalidOperationException(
"Стандартные виды созданы, но пусты (модель не спроецировалась). Проверьте файл модели.");
- return new DrawingViewsResult { Created = newViews.Count, Total = views.Count };
+ return new DrawingViewsResult
+ {
+ Created = newViews.Count,
+ Total = views.Count,
+ ViewNumbers = newViews.Select(v => v.Number).ToArray(),
+ };
}
catch
{
diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingValidation.cs b/src/Kompas.Mcp.Core/Drawings/DrawingValidation.cs
index bb9d0c3..032a2bc 100644
--- a/src/Kompas.Mcp.Core/Drawings/DrawingValidation.cs
+++ b/src/Kompas.Mcp.Core/Drawings/DrawingValidation.cs
@@ -32,4 +32,26 @@ public static class DrawingValidation
if (!double.IsFinite(scale) || scale <= 0)
throw new ArgumentOutOfRangeException(nameof(scale), "Масштаб должен быть конечным и > 0.");
}
+
+ /// Проверить, что все координаты конечны (отсекает NaN/Infinity).
+ public static void RequireFiniteCoords(params double[] coords)
+ {
+ foreach (var c in coords)
+ if (!double.IsFinite(c))
+ throw new ArgumentException("Координаты должны быть конечными числами (не NaN/Infinity).");
+ }
+
+ /// Минимальное расстояние между выносными точками линейного размера (мм).
+ private const double MinPointDistance = 1e-7;
+
+ ///
+ /// Проверить, что выносные точки размера различны (иначе размер вырожден — нулевая длина).
+ ///
+ public static void RequireDistinctPoints(double x1, double y1, double x2, double y2)
+ {
+ var dx = x2 - x1;
+ var dy = y2 - y1;
+ if (Math.Sqrt(dx * dx + dy * dy) < MinPointDistance)
+ throw new ArgumentException("Выносные точки размера совпадают — нельзя поставить размер нулевой длины.");
+ }
}
diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingViewsResult.cs b/src/Kompas.Mcp.Core/Drawings/DrawingViewsResult.cs
index 336cec7..fb36be9 100644
--- a/src/Kompas.Mcp.Core/Drawings/DrawingViewsResult.cs
+++ b/src/Kompas.Mcp.Core/Drawings/DrawingViewsResult.cs
@@ -8,4 +8,7 @@ public sealed record DrawingViewsResult
/// Общее число видов на чертеже после операции (включая системный вид).
public required int Total { get; init; }
+
+ /// Номера созданных видов (для адресации при простановке размеров).
+ public required IReadOnlyList ViewNumbers { get; init; }
}
diff --git a/src/Kompas.Mcp.Host/Tools/DrawingTools.cs b/src/Kompas.Mcp.Host/Tools/DrawingTools.cs
index 9ca2ad8..9341593 100644
--- a/src/Kompas.Mcp.Host/Tools/DrawingTools.cs
+++ b/src/Kompas.Mcp.Host/Tools/DrawingTools.cs
@@ -24,7 +24,9 @@ public sealed class DrawingTools(KompasSession session, DrawingService drawing)
{
await session.ConnectAsync();
var r = await drawing.CreateStandardViewsAsync(partFilePath, scale, x, y);
- return $"Создано {r.Created} стандартных вида на чертеже (всего видов: {r.Total}, масштаб {scale}).";
+ var numbers = string.Join(", ", r.ViewNumbers);
+ return $"Создано {r.Created} стандартных вида на чертеже (всего видов: {r.Total}, масштаб {scale}). " +
+ $"Номера созданных видов: {numbers} (для drawing_add_linear_dimension).";
}
[McpServerTool(Name = "drawing_fill_title_block")]
@@ -41,4 +43,26 @@ public sealed class DrawingTools(KompasSession session, DrawingService drawing)
var filled = await drawing.FillTitleBlockAsync(designation, name, material);
return $"Заполнено граф основной надписи: {filled}.";
}
+
+ [McpServerTool(Name = "drawing_add_linear_dimension")]
+ [Description("Поставить линейный размер на виде активного чертежа между точками (x1,y1)-(x2,y2) " +
+ "в ЛОКАЛЬНОЙ СК вида (мм; при масштабе 1:1 = координаты модели на плоскости вида). Размерная " +
+ "линия проходит через (x3,y3). orientation: horizontal (|X2-X1|) | vertical (|Y2-Y1|) | parallel " +
+ "(длина отрезка). viewNumber — номер вида из drawing_create_standard_views (0 = первый/главный вид). " +
+ "Значение измеряется автоматически. Возвращает измеренное значение и номер вида.")]
+ public async Task AddLinearDimension(
+ [Description("X первой выносной точки (СК вида, мм)")] double x1,
+ [Description("Y первой выносной точки")] double y1,
+ [Description("X второй выносной точки")] double x2,
+ [Description("Y второй выносной точки")] double y2,
+ [Description("X положения размерной линии")] double x3,
+ [Description("Y положения размерной линии")] double y3,
+ [Description("Ориентация: horizontal | vertical | parallel")] string orientation = "horizontal",
+ [Description("Номер вида (0 = первый/главный)")] int viewNumber = 0)
+ {
+ await session.ConnectAsync();
+ var o = DimensionOrientations.Parse(orientation);
+ var r = await drawing.AddLinearDimensionAsync(viewNumber, x1, y1, x2, y2, x3, y3, o);
+ return $"Линейный размер ({orientation}) поставлен на виде {r.ViewNumber}, значение {r.Value:0.###} мм.";
+ }
}
diff --git a/tests/Kompas.Mcp.Tests/DimensionOrientationsTests.cs b/tests/Kompas.Mcp.Tests/DimensionOrientationsTests.cs
new file mode 100644
index 0000000..5cdeaeb
--- /dev/null
+++ b/tests/Kompas.Mcp.Tests/DimensionOrientationsTests.cs
@@ -0,0 +1,24 @@
+using Kompas.Mcp.Core.Drawings;
+
+namespace Kompas.Mcp.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class DimensionOrientationsTests
+{
+ [Theory]
+ [InlineData("horizontal", DimensionOrientation.Horizontal)]
+ [InlineData("vertical", DimensionOrientation.Vertical)]
+ [InlineData("parallel", DimensionOrientation.Parallel)]
+ [InlineData("ГОРИЗОНТАЛЬНЫЙ", DimensionOrientation.Horizontal)]
+ [InlineData(" vertical ", DimensionOrientation.Vertical)]
+ public void Parse_maps_known(string value, DimensionOrientation expected)
+ => Assert.Equal(expected, DimensionOrientations.Parse(value));
+
+ [Fact]
+ public void Parse_throws_on_unknown()
+ => Assert.Throws(() => DimensionOrientations.Parse("diagonal"));
+
+ // ToKompas (→ ksLineDimensionOrientationEnum) намеренно НЕ покрыт unit-тестом: тестовый проект
+ // не ссылается на Kompas6Constants (граница проекта). Маппинг тривиален и проверяется интеграцией
+ // (horizontal → |X2-X1|, vertical → |Y2-Y1|).
+}
diff --git a/tests/Kompas.Mcp.Tests/DrawingValidationTests.cs b/tests/Kompas.Mcp.Tests/DrawingValidationTests.cs
index 0b5ee7b..ab20534 100644
--- a/tests/Kompas.Mcp.Tests/DrawingValidationTests.cs
+++ b/tests/Kompas.Mcp.Tests/DrawingValidationTests.cs
@@ -53,4 +53,19 @@ public sealed class DrawingValidationTests
[InlineData(2.0)]
public void RequireScale_accepts_positive(double scale)
=> Assert.Null(Record.Exception(() => DrawingValidation.RequireScale(scale)));
+
+ [Fact]
+ public void RequireFiniteCoords_throws_on_non_finite()
+ {
+ Assert.Throws(() => DrawingValidation.RequireFiniteCoords(0, double.NaN));
+ Assert.Throws(() => DrawingValidation.RequireFiniteCoords(double.PositiveInfinity));
+ Assert.Null(Record.Exception(() => DrawingValidation.RequireFiniteCoords(0, 1, -5, 40)));
+ }
+
+ [Fact]
+ public void RequireDistinctPoints_throws_on_coincident()
+ {
+ Assert.Throws(() => DrawingValidation.RequireDistinctPoints(10, 20, 10, 20));
+ Assert.Null(Record.Exception(() => DrawingValidation.RequireDistinctPoints(0, 0, 40, 0)));
+ }
}
diff --git a/tests/Kompas.Mcp.Tests/Integration/DrawingDimensionTests.cs b/tests/Kompas.Mcp.Tests/Integration/DrawingDimensionTests.cs
new file mode 100644
index 0000000..4e4cbdb
--- /dev/null
+++ b/tests/Kompas.Mcp.Tests/Integration/DrawingDimensionTests.cs
@@ -0,0 +1,122 @@
+using Kompas.Mcp.Core.Documents;
+using Kompas.Mcp.Core.Drawings;
+using Kompas.Mcp.Core.Modeling;
+
+namespace Kompas.Mcp.Tests.Integration;
+
+/// Интеграция: линейный размер на виде чертежа (API7 ISymbols2DContainer.LineDimensions).
+[Trait("Category", "Integration")]
+[Collection(KompasCollection.Name)]
+public sealed class DrawingDimensionTests : IntegrationTestBase
+{
+ private readonly DocumentService _docs;
+ private readonly PartModeler _modeler;
+ private readonly DrawingService _drawing;
+
+ public DrawingDimensionTests(KompasFixture fx) : base(fx)
+ {
+ _docs = new DocumentService(fx.Session, fx.Dispatcher);
+ _modeler = new PartModeler(fx.Session, fx.Dispatcher);
+ _drawing = new DrawingService(fx.Session, fx.Dispatcher);
+ }
+
+ private async Task BuildAndSaveBoxAsync()
+ {
+ var path = TestPaths.NewFile(".m3d");
+ await _docs.CreateAsync(KompasDocumentType.Part);
+ try
+ {
+ var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
+ await _modeler.AddRectangleAsync(s, 0, 0, 40, 30);
+ await _modeler.CloseSketchAsync(s);
+ await _modeler.ExtrudeAsync(s, depth: 20);
+ await _modeler.RebuildAsync();
+ await _docs.SaveAsAsync(path);
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ return path;
+ }
+
+ [Fact]
+ public async Task Horizontal_dimension_auto_measures_distance()
+ {
+ var partFile = await BuildAndSaveBoxAsync();
+ await _docs.CreateAsync(KompasDocumentType.Drawing);
+ try
+ {
+ var views = await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150);
+ Assert.Equal(3, views.ViewNumbers.Count);
+
+ var r = await _drawing.AddLinearDimensionAsync(
+ viewNumber: 0, x1: 0, y1: 0, x2: 40, y2: 0, x3: 20, y3: -10, DimensionOrientation.Horizontal);
+
+ Assert.InRange(r.Value, 39.9, 40.1); // авто-значение = |X2-X1|
+ Assert.Contains(r.ViewNumber, views.ViewNumbers); // размер попал в один из видов
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Vertical_dimension_measures_y_span()
+ {
+ var partFile = await BuildAndSaveBoxAsync();
+ await _docs.CreateAsync(KompasDocumentType.Drawing);
+ try
+ {
+ await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150);
+ var r = await _drawing.AddLinearDimensionAsync(
+ viewNumber: 0, x1: 0, y1: 0, x2: 0, y2: 20, x3: -10, y3: 10, DimensionOrientation.Vertical);
+ Assert.InRange(r.Value, 19.9, 20.1);
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Targets_specific_view_by_number_and_increments_its_object_count()
+ {
+ var partFile = await BuildAndSaveBoxAsync();
+ await _docs.CreateAsync(KompasDocumentType.Drawing);
+ try
+ {
+ var views = await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150);
+ var targetNumber = views.ViewNumbers[1]; // второй созданный вид (напр. сверху)
+
+ // Размеры живут в ISymbols2DContainer вида — считаем именно их у ЦЕЛЕВОГО вида.
+ var before = await _drawing.GetViewLineDimensionCountAsync(targetNumber);
+ var r = await _drawing.AddLinearDimensionAsync(
+ viewNumber: targetNumber, x1: 0, y1: 0, x2: 40, y2: 0, x3: 20, y3: -10, DimensionOrientation.Horizontal);
+ var after = await _drawing.GetViewLineDimensionCountAsync(targetNumber);
+
+ Assert.Equal(targetNumber, r.ViewNumber);
+ Assert.Equal(before + 1, after); // размер попал именно в целевой вид
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Coincident_points_throw()
+ {
+ var partFile = await BuildAndSaveBoxAsync();
+ await _docs.CreateAsync(KompasDocumentType.Drawing);
+ try
+ {
+ await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150);
+ await Assert.ThrowsAsync(() => _drawing.AddLinearDimensionAsync(
+ viewNumber: 0, x1: 10, y1: 10, x2: 10, y2: 10, x3: 0, y3: 0, DimensionOrientation.Horizontal));
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task No_views_throws()
+ {
+ // Чертёж без ассоциативных видов: только системный вид → понятная ошибка.
+ await _docs.CreateAsync(KompasDocumentType.Drawing);
+ try
+ {
+ await Assert.ThrowsAsync(() => _drawing.AddLinearDimensionAsync(
+ viewNumber: 0, x1: 0, y1: 0, x2: 40, y2: 0, x3: 20, y3: -10, DimensionOrientation.Horizontal));
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+}