feat: drawing_add_linear_dimension — линейный размер на виде (API7)

Веха 2D-ЧЕРТЁЖ, инкремент 3. DrawingService.AddLinearDimensionAsync: (ISymbols2D
Container)view (QI) → LineDimensions.Add → X1..Y3 (локальная СК вида) + Orientation
+ AutoNominalValue → Update → проверка Valid → ((IDimensionText)dim).NominalValue.
Свой enum DimensionOrientation (horizontal/vertical/parallel). Новый инструмент
drawing_add_linear_dimension. DrawingViewsResult += ViewNumbers (адресация видов).

Находка: размеры НЕ в IView.ObjectCount (он про геометрию IDrawingContainer) —
проверка попадания в вид через ISymbols2DContainer.LineDimensions.Count.

Ревью Codex спека учтено: RequireDistinctPoints (нулевая длина) + Valid после
Update; контракт «первый несистемный вид»; null-check Add; AutoNominalValue явно;
ViewNumbers required + текст ответа create_standard_views; тесты адресации/негатив.

Тесты: +8 unit (DimensionOrientations, DrawingValidation), +5 integration
(DrawingDimensionTests). Итого 131 unit + 70 integration = 201.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 18:01:02 +03:00
parent ca3d91ae68
commit 8e0ab374af
10 changed files with 365 additions and 3 deletions
@@ -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) — будущее.
## Цель
@@ -0,0 +1,41 @@
using Kompas6Constants;
namespace Kompas.Mcp.Core.Drawings;
/// <summary>Ориентация линейного размера (обёртка над <see cref="ksLineDimensionOrientationEnum"/>).</summary>
public enum DimensionOrientation
{
/// <summary>Горизонтальный (измеряет |X2X1|).</summary>
Horizontal,
/// <summary>Вертикальный (измеряет |Y2Y1|).</summary>
Vertical,
/// <summary>Параллельный отрезку P1→P2 (реальная длина).</summary>
Parallel,
}
/// <summary>Маппинг ориентации размера ↔ перечисление КОМПАС и разбор строки.</summary>
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, "Неизвестная ориентация размера."),
};
/// <summary>Разобрать строку ориентации (англ./рус.) в <see cref="DimensionOrientation"/>.</summary>
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)),
};
}
}
@@ -0,0 +1,11 @@
namespace Kompas.Mcp.Core.Drawings;
/// <summary>Результат простановки размера на чертеже.</summary>
public sealed record DrawingDimensionResult
{
/// <summary>Измеренное значение размера (мм).</summary>
public required double Value { get; init; }
/// <summary>Номер вида, на котором поставлен размер.</summary>
public required int ViewNumber { get; init; }
}
+79 -1
View File
@@ -84,6 +84,79 @@ public sealed class DrawingService
?? throw new InvalidOperationException("У листа оформления нет основной надписи (Stamp).");
}
/// <summary>
/// Поставить линейный размер на виде активного чертежа между точками (x1,y1)-(x2,y2) в ЛОКАЛЬНОЙ
/// СК вида (мм); размерная линия проходит через (x3,y3). <paramref name="viewNumber"/> — номер вида
/// (≤0 = первый несистемный/главный). Значение измеряется автоматически. Возвращает значение и номер вида.
/// </summary>
public Task<DrawingDimensionResult> 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;
}
}
/// <summary>Число линейных размеров на виде по номеру (для проверки/диагностики). Размеры живут
/// в ISymbols2DContainer вида, а НЕ в IView.ObjectCount (тот считает геометрию).</summary>
public Task<int> GetViewLineDimensionCountAsync(int viewNumber, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var symbols = (ISymbols2DContainer)FindView(viewNumber);
return symbols.LineDimensions?.Count ?? 0;
}, ct);
/// <summary>Найти вид: при viewNumber ≤ 0 — первый несистемный (Number != 0); иначе по номеру.</summary>
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
{
@@ -32,4 +32,26 @@ public static class DrawingValidation
if (!double.IsFinite(scale) || scale <= 0)
throw new ArgumentOutOfRangeException(nameof(scale), "Масштаб должен быть конечным и > 0.");
}
/// <summary>Проверить, что все координаты конечны (отсекает NaN/Infinity).</summary>
public static void RequireFiniteCoords(params double[] coords)
{
foreach (var c in coords)
if (!double.IsFinite(c))
throw new ArgumentException("Координаты должны быть конечными числами (не NaN/Infinity).");
}
/// <summary>Минимальное расстояние между выносными точками линейного размера (мм).</summary>
private const double MinPointDistance = 1e-7;
/// <summary>
/// Проверить, что выносные точки размера различны (иначе размер вырожден — нулевая длина).
/// </summary>
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("Выносные точки размера совпадают — нельзя поставить размер нулевой длины.");
}
}
@@ -8,4 +8,7 @@ public sealed record DrawingViewsResult
/// <summary>Общее число видов на чертеже после операции (включая системный вид).</summary>
public required int Total { get; init; }
/// <summary>Номера созданных видов (для адресации при простановке размеров).</summary>
public required IReadOnlyList<int> ViewNumbers { get; init; }
}
+25 -1
View File
@@ -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<string> 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.###} мм.";
}
}
@@ -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<ArgumentException>(() => DimensionOrientations.Parse("diagonal"));
// ToKompas (→ ksLineDimensionOrientationEnum) намеренно НЕ покрыт unit-тестом: тестовый проект
// не ссылается на Kompas6Constants (граница проекта). Маппинг тривиален и проверяется интеграцией
// (horizontal → |X2-X1|, vertical → |Y2-Y1|).
}
@@ -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<ArgumentException>(() => DrawingValidation.RequireFiniteCoords(0, double.NaN));
Assert.Throws<ArgumentException>(() => DrawingValidation.RequireFiniteCoords(double.PositiveInfinity));
Assert.Null(Record.Exception(() => DrawingValidation.RequireFiniteCoords(0, 1, -5, 40)));
}
[Fact]
public void RequireDistinctPoints_throws_on_coincident()
{
Assert.Throws<ArgumentException>(() => DrawingValidation.RequireDistinctPoints(10, 20, 10, 20));
Assert.Null(Record.Exception(() => DrawingValidation.RequireDistinctPoints(0, 0, 40, 0)));
}
}
@@ -0,0 +1,122 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Drawings;
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>Интеграция: линейный размер на виде чертежа (API7 ISymbols2DContainer.LineDimensions).</summary>
[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<string> 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<ArgumentException>(() => _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<InvalidOperationException>(() => _drawing.AddLinearDimensionAsync(
viewNumber: 0, x1: 0, y1: 0, x2: 40, y2: 0, x3: 20, y3: -10, DimensionOrientation.Horizontal));
}
finally { await _docs.CloseAsync(save: false); }
}
}