diff --git a/docs/superpowers/specs/2026-05-27-drawing-radial-angular-dimension-design.md b/docs/superpowers/specs/2026-05-27-drawing-radial-angular-dimension-design.md
new file mode 100644
index 0000000..35a1331
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-27-drawing-radial-angular-dimension-design.md
@@ -0,0 +1,165 @@
+# Дизайн: радиальный (R) и угловой размеры на виде чертежа через API7
+
+**Дата:** 2026-05-27
+**Статус:** реализовано и проверено (5 радиальных + 8 угловых интеграционных + 9 unit; всего 240 тестов зелёных); ревью Codex спека учтено
+
+## Уточнение по реализации (важно)
+
+Спайк изначально решил, что измеряемый сектор выбирает положение дуги `(x3,y3)`. **При реализации
+выяснилось иначе:** сектор (90° vs 270°) выбирает **`angleType`** (`ksADMinAngle`/`MaxAngle`/`MoreAngle`),
+а `(x3,y3)` лишь позиционирует размерную дугу и задаёт её радиус. (270° в спайке возникал потому, что
+`X3/Y3` не были заданы = совпадали с вершиной → вырождение.) Проверено: `angleType=Min`→90°,
+`angleType=More`→270° на одной геометрии. Тест #8 и описания инструмента/сервиса исправлены под это.
+
+## Правки по ревью Codex (спек)
+
+- **#1** угловой: добавлен `RequireDistinctPoints(x1,y1,x2,y2)` (совпавшие точки сторон → одинаковые
+ лучи → вырождение ещё до `Add`).
+- **#3** добавлен тест отката после `Add`: коллинеарные сонаправленные лучи (вершина(0,0),(10,0),(20,0))
+ → угол ≈0 → пост-проверка `value<=0` → откат `Delete` + ошибка; счётчик угловых не изменился.
+- **#2** поведение `x3,y3` (выбор сектора) задокументировано в описании инструмента; добавлен
+ интеграционный тест выбора сектора (та же геометрия, `x3,y3` в дополнительном секторе → ~270°).
+ Большой/тупой угол НЕ отвергаем — это легитимный результат (рефлексный угол через `more`/положение дуги).
+- **#4** добавлены тесты на несуществующий `viewNumber>0` (ветка `FindView` «вид не найден») для обоих.
+- **#5** `DrawingDimensionResult.Value` — XML-док станет единице-нейтральным («значение размера»);
+ ответ `drawing_add_angular_dimension` форматируется в градусах (°), не «мм».
+- **#6** радиальный использует `DimensionAngles.ToRadians(angleDeg)` (как диаметральный), не ручное `·π/180`.
+
+## Цель
+
+Веха 2D-ЧЕРТЁЖ, инкремент 5. После линейного и диаметрального размеров — **радиальный (R)** и
+**угловой** размеры на виде. Завершают базовое семейство размеров чертежа. Оба переиспользуют
+проверенный путь `ISymbols2DContainer` (QI от `IView`) и паттерн «Add → set → Update → проверка
+`Valid` → чтение `NominalValue` → откат `Delete` при ошибке».
+
+## Спайк: поведение подтверждено вживую (НЕ разучивать)
+
+Спайк (`_SpikeDimensions`, прогнан на реальном КОМПАС, затем удалён) на коробке 40×30×20 с
+тремя стандартными видами. Контейнер размеров — `(ISymbols2DContainer)view`.
+
+### Радиальный
+
+```
+IRadialDimension rd = symbols.RadialDimensions.Add();
+rd.Xc = 10; rd.Yc = 10; rd.Radius = 15; rd.Angle = π/4; // Angle — РАДИАНЫ (направление выноски)
+rd.DimensionType = true; // BOOL: на значение НЕ влияет (стиль)
+((IDimensionText)rd).AutoNominalValue = true;
+rd.Update(); // True, rd.Valid == True
+double value = ((IDimensionText)rd).NominalValue; // == 15 — РАДИУС (не диаметр!)
+```
+
+**Ключевой факт (расходится со справкой SDK):** у радиального `NominalValue` возвращает **радиус**
+(`Radius=15 → NominalValue=15`), а НЕ диаметр. Проверено для `DimensionType` и `true`, и `false` —
+оба `Valid`, значение одинаковое (BOOL влияет лишь на стиль «от центра / нет», не на величину).
+Координаты — локальная СК вида, мм. Размер «свободный» (по `Xc/Yc/Radius`, без `BaseObject`).
+
+### Угловой
+
+```
+IAngleDimension ad = symbols.AngleDimensions.Add(DrawingObjectTypeEnum.ksDrADimension); // =10
+ad.Xc = 0; ad.Yc = 0; // вершина угла
+ad.X1 = 10; ad.Y1 = 0; // точка на стороне 1 → луч (Xc,Yc)→(X1,Y1)
+ad.X2 = 0; ad.Y2 = 10; // точка на стороне 2 → луч (Xc,Yc)→(X2,Y2)
+ad.X3 = 5; ad.Y3 = 5; // положение размерной дуги — ЗАДАЁТ ИЗМЕРЯЕМЫЙ СЕКТОР + радиус дуги
+ad.DimensionType = ksADMinAngle; // 0=min / 1=max / 2=more180
+((IDimensionText)ad).AutoNominalValue = true;
+ad.Update(); // True, ad.Valid == True
+double value = ((IDimensionText)ad).NominalValue; // == 90 — ГРАДУСЫ
+```
+
+**Проверено:**
+- вершина(0,0), точки (10,0)&(0,10), `X3/Y3`=(5,5) → **90°** ✓;
+- вершина(0,0), точки (10,0)&(10,10), `X3/Y3`=(8,4) → **45°** ✓.
+- **`X3/Y3` критичен:** без него тот же угол измерился как **270°** (дополнительный сектор). Точка
+ положения дуги задаёт, какой из двух секторов меряется; заодно радиус дуги пересчитывается в
+ `dist(вершина, X3/Y3)` (заданный `Radius` перетирается → его не экспонируем).
+- Подход через `Angle1/Angle2` (углы наклона сторон) — **вырожденный** (точки схлопываются к
+ вершине, `X1≈0.001`), не используем. Геометрию ведут только координаты точек.
+- `NominalValue` — в **градусах**.
+
+`Add(DrawingObjectTypeEnum)` требует код типа: `ksDrADimension = 10` (обычный угловой),
+`ksDrABreakDimension = 39` (с обрывом — вне объёма).
+
+## MCP-инструменты
+
+| Инструмент | Параметры | Поведение |
+|---|---|---|
+| `drawing_add_radial_dimension` | `xc,yc` (центр окружности), `radius`, `angle=0` (°), `viewNumber=0` | Радиальный размер (R) окружности/дуги с центром (xc,yc) и радиусом `radius` (ЛСК вида, мм). `angle` — направление выноски в ГРАДУСАХ (внутри → радианы). `viewNumber`: 0 = первый/главный. Значение (= радиус) измеряется автоматически. Возвращает радиус и номер вида. |
+| `drawing_add_angular_dimension` | `xc,yc` (вершина), `x1,y1` (точка на стороне 1), `x2,y2` (точка на стороне 2), `x3,y3` (положение размерной дуги — задаёт сектор), `angleType="min"`, `viewNumber=0` | Угловой размер между лучами (xc,yc)→(x1,y1) и (xc,yc)→(x2,y2) (ЛСК вида, мм). `x3,y3` должны лежать в измеряемом секторе. `angleType`: min/max/more. Значение (градусы) измеряется автоматически. Возвращает угол (°) и номер вида. |
+
+Углы выноски (`angle`) экспонируются в **градусах** (LLM-дружелюбно), внутри `·π/180`.
+
+## Архитектура
+
+В существующем `DrawingService` (рядом с `AddDiametralDimensionAsync`); переиспользуем
+`RequireSymbols2DContainer(viewNumber)`, `DrawingDimensionResult`, паттерн отката.
+
+- `DrawingService.AddRadialDimensionAsync(viewNumber, xc, yc, radius, angleDeg, ct)`.
+- `DrawingService.AddAngularDimensionAsync(viewNumber, xc, yc, x1, y1, x2, y2, x3, y3, angleType, ct)`.
+- Инструменты `drawing_add_radial_dimension`, `drawing_add_angular_dimension` в `DrawingTools`.
+- Helpers `GetViewRadialDimensionCountAsync`, `GetViewAngularDimensionCountAsync` (для тестов —
+ попадание в вид; размеры в `ISymbols2DContainer.RadialDimensions/AngleDimensions.Count`, не в
+ `IView.ObjectCount`).
+- Новый файл `src/Kompas.Mcp.Core/Drawings/AngleDimensionType.cs`: `enum AngleDimensionType {Min,Max,More}`
+ + static `AngleDimensionTypes.Parse(string)` / `ToKompas(...)` (→ `ksADMinAngle/ksADMaxAngle/ksADMoreAngle`),
+ по образцу `DimensionOrientations`.
+
+`DrawingDimensionResult` (Value + ViewNumber) переиспользуется: радиальный `Value`=радиус, угловой
+`Value`=градусы.
+
+## Валидация (чистые static, unit-тест)
+
+- Радиальный: `RequireFiniteCoords(xc, yc, angleDeg)` + `RequirePositiveRadius(radius)` — уже есть.
+- Угловой: `RequireFiniteCoords(xc,yc,x1,y1,x2,y2,x3,y3)` (8 координат) + `RequireDistinctPoints`
+ четырежды: вершина≠(x1,y1), вершина≠(x2,y2), вершина≠(x3,y3) (иначе дуга нулевого радиуса),
+ (x1,y1)≠(x2,y2) (#1: совпавшие точки сторон → одинаковые лучи). Все уже есть; новых валидаторов не требуется.
+
+## Реализация
+
+**`AddRadialDimensionCore`** (зеркало диаметрального, но значение = радиус): валидация → `RequireSymbols2DContainer`
+→ `RadialDimensions.Add()` (null-check) → `Xc/Yc/Radius`, `Angle=DimensionAngles.ToRadians(angleDeg)` (#6), `DimensionType=true`
+(стиль R от центра, явно), `AutoNominalValue=true` → `Update()` (FALSE → откат `Delete`+ошибка) →
+`Valid` (false → откат) → `value = NominalValue` (радиус); `value<=0 || !IsFinite` → откат+ошибка →
+`DrawingDimensionResult{Value, ViewNumber}`.
+
+**`AddAngularDimensionCore`**: валидация → `RequireSymbols2DContainer` → `AngleDimensions.Add(ksDrADimension)`
+(null-check) → `Xc/Yc/X1/Y1/X2/Y2/X3/Y3`, `DimensionType=AngleDimensionTypes.ToKompas(angleType)`,
+`AutoNominalValue=true` → `Update()` (FALSE → откат) → `Valid` (false → откат) → `value=NominalValue`
+(°); `value<=0 || !IsFinite` → откат+ошибка с подсказкой (вырожденный угол: стороны коллинеарны или
+`x3,y3` вне сектора) → `DrawingDimensionResult{Value, ViewNumber}`. Угол в (0,360); защиту по верхней
+границе не форсируем (КОМПАС нормализует, `more` может дать >180).
+
+RCW точечно не освобождаем (консистентно с линейным/диаметральным; долг v2-2).
+
+## Тестирование
+
+### Unit (`DrawingDimensionTypeTests` — новый, либо в `DrawingValidationTests`)
+- `AngleDimensionTypes.Parse`: `min/max/more` (+ рус. синонимы) → enum; неизвестное → `ArgumentException`.
+- `AngleDimensionTypes.ToKompas`: маппинг трёх значений → `ksADMinAngle/ksADMaxAngle/ksADMoreAngle`.
+- (`ToRadians`, `RequirePositiveRadius`, координаты — уже покрыты.)
+
+### Integration (`DrawingRadialTests`, `DrawingAngularTests`; наследуют `IntegrationTestBase`)
+Радиальный:
+1. **Значение = радиус**: `radius=12 → Value ≈ 12` (промотированный спайк).
+2. **Попадание в вид**: `RadialDimensions.Count` целевого вида +1 (адресация по `viewNumber`).
+3. **Радиус ≤ 0** → `ArgumentOutOfRangeException` (до `Add`).
+4. **Нет видов** → понятная ошибка.
+
+Угловой:
+5. **Измеряет 90°**: вершина(0,0), (10,0)&(0,10), x3/y3=(5,5) → `Value ≈ 90`.
+6. **Измеряет 45°**: вершина(0,0), (10,0)&(10,10), x3/y3=(8,4) → `Value ≈ 45`.
+7. **Попадание в вид**: `AngleDimensions.Count` целевого вида +1.
+8. **Выбор угла типом** (#2): та же геометрия 90°, но `angleType=More` → `Value ≈ 270`
+ (документированное поведение: какой из двух углов измерять, выбирает `angleType`, не `x3,y3`).
+9. **Вырожденный угол до Add** (вершина совпадает с точкой стороны) → `ArgumentException`.
+10. **Откат после Add** (#3): коллинеарные сонаправленные лучи (0,0),(10,0),(20,0) → угол ≈0 →
+ `InvalidOperationException`; `AngleDimensions.Count` целевого вида НЕ изменился (откат `Delete`).
+11. **Несуществующий `viewNumber>0`** (#4) → понятная ошибка (ветка `FindView`).
+12. **Нет видов** → понятная ошибка.
+
+(Радиальный — аналогично добавлен тест на несуществующий `viewNumber>0`, #4.)
+
+## Дальнейшее (вне спека)
+- Ассоциативная привязка размеров к геометрии вида (`BaseObject`/`BaseObject1/2`); рамка/формат листа;
+ текстовые обозначения (шероховатость `Roughs`, допуски формы `Tolerances`, выноски `Leaders`,
+ тех. требования); радиальный с изломом (`BreakRadialDimensions`), угловой с обрывом (`ksDrABreakDimension`).
diff --git a/src/Kompas.Mcp.Core/Drawings/AngleDimensionType.cs b/src/Kompas.Mcp.Core/Drawings/AngleDimensionType.cs
new file mode 100644
index 0000000..fe86d67
--- /dev/null
+++ b/src/Kompas.Mcp.Core/Drawings/AngleDimensionType.cs
@@ -0,0 +1,42 @@
+using Kompas6Constants;
+
+namespace Kompas.Mcp.Core.Drawings;
+
+/// Тип углового размера — какой из двух секторов измерять
+/// (обёртка над ).
+public enum AngleDimensionType
+{
+ /// Минимальный (острый) угол.
+ Min,
+ /// Максимальный (тупой) угол.
+ Max,
+ /// Угол более 180° (рефлексный).
+ More,
+}
+
+/// Маппинг типа углового размера ↔ перечисление КОМПАС и разбор строки.
+public static class AngleDimensionTypes
+{
+ public static ksAngleDimTypeEnum ToKompas(AngleDimensionType type) => type switch
+ {
+ AngleDimensionType.Min => ksAngleDimTypeEnum.ksADMinAngle,
+ AngleDimensionType.Max => ksAngleDimTypeEnum.ksADMaxAngle,
+ AngleDimensionType.More => ksAngleDimTypeEnum.ksADMoreAngle,
+ _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Неизвестный тип углового размера."),
+ };
+
+ /// Разобрать строку типа углового размера (англ./рус.) в .
+ public static AngleDimensionType Parse(string value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ return value.Trim().ToLowerInvariant() switch
+ {
+ "min" or "minimal" or "минимальный" or "острый" => AngleDimensionType.Min,
+ "max" or "maximal" or "максимальный" or "тупой" => AngleDimensionType.Max,
+ "more" or "more180" or "больше180" or "рефлексный" => AngleDimensionType.More,
+ _ => throw new ArgumentException(
+ $"Неизвестный тип углового размера '{value}'. Допустимо: min, max, more.",
+ nameof(value)),
+ };
+ }
+}
diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingDimensionResult.cs b/src/Kompas.Mcp.Core/Drawings/DrawingDimensionResult.cs
index 3ccc082..b8f40d4 100644
--- a/src/Kompas.Mcp.Core/Drawings/DrawingDimensionResult.cs
+++ b/src/Kompas.Mcp.Core/Drawings/DrawingDimensionResult.cs
@@ -3,7 +3,7 @@ namespace Kompas.Mcp.Core.Drawings;
/// Результат простановки размера на чертеже.
public sealed record DrawingDimensionResult
{
- /// Измеренное значение размера (мм).
+ /// Измеренное значение размера: линейный/диаметральный/радиальный — мм, угловой — градусы.
public required double Value { get; init; }
/// Номер вида, на котором поставлен размер.
diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingService.cs b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs
index 8cb8b6c..37a2afb 100644
--- a/src/Kompas.Mcp.Core/Drawings/DrawingService.cs
+++ b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs
@@ -184,6 +184,109 @@ public sealed class DrawingService
public Task GetViewDiametralDimensionCountAsync(int viewNumber, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => RequireSymbols2DContainer(viewNumber).Symbols.DiametralDimensions?.Count ?? 0, ct);
+ ///
+ /// Поставить радиальный размер (R) окружности/дуги с центром (xc,yc) и радиусом
+ /// (ЛОКАЛЬНАЯ СК вида, мм) на виде. — направление выноски (градусы).
+ /// Значение (= радиус) измеряется автоматически. Возвращает радиус и номер вида.
+ ///
+ public Task AddRadialDimensionAsync(int viewNumber, double xc, double yc,
+ double radius, double angleDeg, CancellationToken ct = default)
+ => _dispatcher.InvokeAsync(() => AddRadialDimensionCore(viewNumber, xc, yc, radius, angleDeg), ct);
+
+ private DrawingDimensionResult AddRadialDimensionCore(int viewNumber, double xc, double yc,
+ double radius, double angleDeg)
+ {
+ DrawingValidation.RequireFiniteCoords(xc, yc);
+ DrawingValidation.RequirePositiveRadius(radius);
+ var angleRad = DimensionAngles.ToRadians(angleDeg);
+
+ var (view, symbols) = RequireSymbols2DContainer(viewNumber);
+ var dim = symbols.RadialDimensions?.Add()
+ ?? throw new InvalidOperationException("RadialDimensions.Add вернул null.");
+ try
+ {
+ dim.Xc = xc; dim.Yc = yc; dim.Radius = radius; dim.Angle = angleRad;
+ dim.DimensionType = true; // стиль R «от центра» (на величину не влияет — спайк)
+ ((IDimensionText)dim).AutoNominalValue = true;
+
+ if (!dim.Update())
+ throw new InvalidOperationException("IRadialDimension.Update вернул FALSE — размер не построен.");
+ if (!dim.Valid)
+ throw new InvalidOperationException("Радиальный размер построен невалидным.");
+
+ var value = ((IDimensionText)dim).NominalValue; // = радиус (не диаметр — спайк)
+ if (value <= 0 || !double.IsFinite(value)) // инвариант радиуса: строго положительный
+ throw new InvalidOperationException($"Радиальный размер измерил некорректное значение ({value}).");
+ return new DrawingDimensionResult { Value = value, ViewNumber = view.Number };
+ }
+ catch
+ {
+ try { dim.Delete(); } catch { /* лучшее усилие */ }
+ throw;
+ }
+ }
+
+ /// Число радиальных размеров на виде (для проверки попадания в вид — они в
+ /// ISymbols2DContainer, а не в IView.ObjectCount).
+ public Task GetViewRadialDimensionCountAsync(int viewNumber, CancellationToken ct = default)
+ => _dispatcher.InvokeAsync(() => RequireSymbols2DContainer(viewNumber).Symbols.RadialDimensions?.Count ?? 0, ct);
+
+ ///
+ /// Поставить угловой размер между лучами (xc,yc)→(x1,y1) и (xc,yc)→(x2,y2) (ЛОКАЛЬНАЯ СК вида, мм)
+ /// на виде. (x3,y3) — положение размерной дуги (задаёт её радиус = расстояние от вершины).
+ /// выбирает, какой из двух углов измерять (min/max/more) — например
+ /// 90° или 270° для перпендикуляра. Значение (градусы) измеряется автоматически. Возвращает угол (°) и номер вида.
+ ///
+ public Task AddAngularDimensionAsync(int viewNumber, double xc, double yc,
+ double x1, double y1, double x2, double y2, double x3, double y3,
+ AngleDimensionType angleType, CancellationToken ct = default)
+ => _dispatcher.InvokeAsync(() =>
+ AddAngularDimensionCore(viewNumber, xc, yc, x1, y1, x2, y2, x3, y3, angleType), ct);
+
+ private DrawingDimensionResult AddAngularDimensionCore(int viewNumber, double xc, double yc,
+ double x1, double y1, double x2, double y2, double x3, double y3, AngleDimensionType angleType)
+ {
+ DrawingValidation.RequireFiniteCoords(xc, yc, x1, y1, x2, y2, x3, y3);
+ DrawingValidation.RequireDistinctPoints(xc, yc, x1, y1); // вершина ≠ точка стороны 1
+ DrawingValidation.RequireDistinctPoints(xc, yc, x2, y2); // вершина ≠ точка стороны 2
+ DrawingValidation.RequireDistinctPoints(xc, yc, x3, y3); // вершина ≠ положение дуги (R дуги > 0)
+ DrawingValidation.RequireDistinctPoints(x1, y1, x2, y2); // стороны не совпадают (одинаковые лучи)
+
+ var (view, symbols) = RequireSymbols2DContainer(viewNumber);
+ var dim = symbols.AngleDimensions?.Add(DrawingObjectTypeEnum.ksDrADimension)
+ ?? throw new InvalidOperationException("AngleDimensions.Add вернул null.");
+ try
+ {
+ dim.Xc = xc; dim.Yc = yc;
+ dim.X1 = x1; dim.Y1 = y1; dim.X2 = x2; dim.Y2 = y2; dim.X3 = x3; dim.Y3 = y3;
+ dim.DimensionType = AngleDimensionTypes.ToKompas(angleType);
+ ((IDimensionText)dim).AutoNominalValue = true;
+
+ if (!dim.Update())
+ throw new InvalidOperationException("IAngleDimension.Update вернул FALSE — размер не построен.");
+ if (!dim.Valid)
+ throw new InvalidOperationException("Угловой размер построен невалидным.");
+
+ var value = ((IDimensionText)dim).NominalValue; // градусы
+ // Нулевой/некорректный угол — стороны коллинеарны (сонаправлены) или (x3,y3) вне сектора.
+ if (value <= 0 || !double.IsFinite(value))
+ throw new InvalidOperationException(
+ $"Угловой размер измерил некорректное значение ({value}°). Стороны коллинеарны или " +
+ "положение дуги (x3,y3) задаёт вырожденный сектор.");
+ return new DrawingDimensionResult { Value = value, ViewNumber = view.Number };
+ }
+ catch
+ {
+ try { dim.Delete(); } catch { /* лучшее усилие */ }
+ throw;
+ }
+ }
+
+ /// Число угловых размеров на виде (для проверки попадания в вид — они в
+ /// ISymbols2DContainer, а не в IView.ObjectCount).
+ public Task GetViewAngularDimensionCountAsync(int viewNumber, CancellationToken ct = default)
+ => _dispatcher.InvokeAsync(() => RequireSymbols2DContainer(viewNumber).Symbols.AngleDimensions?.Count ?? 0, ct);
+
/// Вид + его ISymbols2DContainer (QI от вида). Общий для линейных/диаметральных/… размеров.
private (IView View, ISymbols2DContainer Symbols) RequireSymbols2DContainer(int viewNumber)
{
diff --git a/src/Kompas.Mcp.Host/Tools/DrawingTools.cs b/src/Kompas.Mcp.Host/Tools/DrawingTools.cs
index 93cbec5..def3692 100644
--- a/src/Kompas.Mcp.Host/Tools/DrawingTools.cs
+++ b/src/Kompas.Mcp.Host/Tools/DrawingTools.cs
@@ -83,4 +83,48 @@ public sealed class DrawingTools(KompasSession session, DrawingService drawing)
var r = await drawing.AddDiametralDimensionAsync(viewNumber, xc, yc, radius, angle);
return $"Диаметральный размер поставлен на виде {r.ViewNumber}, Ø{r.Value:0.###} мм.";
}
+
+ [McpServerTool(Name = "drawing_add_radial_dimension")]
+ [Description("Поставить радиальный размер (R) окружности/дуги на виде активного чертежа. Окружность " +
+ "задаётся центром (xc,yc) и радиусом radius в ЛОКАЛЬНОЙ СК вида (мм; при масштабе 1:1 = координаты " +
+ "модели на плоскости вида) — задайте параметры реальной окружности вида. angle — направление выноски " +
+ "в градусах. viewNumber — номер вида из drawing_create_standard_views (0 = первый/главный). " +
+ "Значение (= радиус) измеряется автоматически. Возвращает радиус и номер вида.")]
+ public async Task AddRadialDimension(
+ [Description("X центра окружности (СК вида, мм)")] double xc,
+ [Description("Y центра окружности")] double yc,
+ [Description("Радиус окружности, мм")] double radius,
+ [Description("Направление выноски, градусы")] double angle = 0,
+ [Description("Номер вида (0 = первый/главный)")] int viewNumber = 0)
+ {
+ await session.ConnectAsync();
+ var r = await drawing.AddRadialDimensionAsync(viewNumber, xc, yc, radius, angle);
+ return $"Радиальный размер поставлен на виде {r.ViewNumber}, R{r.Value:0.###} мм.";
+ }
+
+ [McpServerTool(Name = "drawing_add_angular_dimension")]
+ [Description("Поставить угловой размер между двумя сторонами на виде активного чертежа. Угол задаётся " +
+ "лучами из вершины (xc,yc): сторона 1 проходит через точку (x1,y1), сторона 2 — через (x2,y2) " +
+ "(всё в ЛОКАЛЬНОЙ СК вида, мм). Точка (x3,y3) — положение размерной дуги (задаёт её радиус = " +
+ "расстояние от вершины); поместите её в стороне измеряемого угла. КАКОЙ из двух углов измерять " +
+ "(например 90° или 270° для перпендикуляра) — выбирает angleType: min (меньший/острый) | max " +
+ "(больший/тупой) | more (рефлексный, >180°). viewNumber — номер вида (0 = первый/главный). " +
+ "Значение (градусы) измеряется автоматически. Возвращает угол (°) и номер вида.")]
+ public async Task AddAngularDimension(
+ [Description("X вершины угла (СК вида, мм)")] double xc,
+ [Description("Y вершины угла")] double yc,
+ [Description("X точки на стороне 1")] double x1,
+ [Description("Y точки на стороне 1")] double y1,
+ [Description("X точки на стороне 2")] double x2,
+ [Description("Y точки на стороне 2")] double y2,
+ [Description("X положения размерной дуги (внутри измеряемого сектора)")] double x3,
+ [Description("Y положения размерной дуги")] double y3,
+ [Description("Тип угла: min | max | more")] string angleType = "min",
+ [Description("Номер вида (0 = первый/главный)")] int viewNumber = 0)
+ {
+ await session.ConnectAsync();
+ var t = AngleDimensionTypes.Parse(angleType);
+ var r = await drawing.AddAngularDimensionAsync(viewNumber, xc, yc, x1, y1, x2, y2, x3, y3, t);
+ return $"Угловой размер поставлен на виде {r.ViewNumber}, {r.Value:0.###}°.";
+ }
}
diff --git a/tests/Kompas.Mcp.Tests/AngleDimensionTypesTests.cs b/tests/Kompas.Mcp.Tests/AngleDimensionTypesTests.cs
new file mode 100644
index 0000000..b315145
--- /dev/null
+++ b/tests/Kompas.Mcp.Tests/AngleDimensionTypesTests.cs
@@ -0,0 +1,30 @@
+using Kompas.Mcp.Core.Drawings;
+
+namespace Kompas.Mcp.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class AngleDimensionTypesTests
+{
+ [Theory]
+ [InlineData("min", AngleDimensionType.Min)]
+ [InlineData("max", AngleDimensionType.Max)]
+ [InlineData("more", AngleDimensionType.More)]
+ [InlineData(" MIN ", AngleDimensionType.Min)]
+ [InlineData("минимальный", AngleDimensionType.Min)]
+ [InlineData("максимальный", AngleDimensionType.Max)]
+ [InlineData("больше180", AngleDimensionType.More)]
+ public void Parse_maps_known(string value, AngleDimensionType expected)
+ => Assert.Equal(expected, AngleDimensionTypes.Parse(value));
+
+ [Fact]
+ public void Parse_throws_on_unknown()
+ => Assert.Throws(() => AngleDimensionTypes.Parse("acute"));
+
+ [Fact]
+ public void Parse_throws_on_null()
+ => Assert.Throws(() => AngleDimensionTypes.Parse(null!));
+
+ // ToKompas (→ ksAngleDimTypeEnum) намеренно НЕ покрыт unit-тестом: тестовый проект не ссылается
+ // на Kompas6Constants (граница проекта, как у DimensionOrientations.ToKompas). Маппинг тривиален
+ // и проверяется интеграцией (min → острый угол 90°, выбор сектора).
+}
diff --git a/tests/Kompas.Mcp.Tests/Integration/DrawingAngularTests.cs b/tests/Kompas.Mcp.Tests/Integration/DrawingAngularTests.cs
new file mode 100644
index 0000000..e582821
--- /dev/null
+++ b/tests/Kompas.Mcp.Tests/Integration/DrawingAngularTests.cs
@@ -0,0 +1,178 @@
+using Kompas.Mcp.Core.Documents;
+using Kompas.Mcp.Core.Drawings;
+using Kompas.Mcp.Core.Modeling;
+
+namespace Kompas.Mcp.Tests.Integration;
+
+/// Интеграция: угловой размер на виде (API7 ISymbols2DContainer.AngleDimensions).
+[Trait("Category", "Integration")]
+[Collection(KompasCollection.Name)]
+public sealed class DrawingAngularTests : IntegrationTestBase
+{
+ private readonly DocumentService _docs;
+ private readonly PartModeler _modeler;
+ private readonly DrawingService _drawing;
+
+ public DrawingAngularTests(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;
+ }
+
+ private async Task NewDrawingWithViewsAsync()
+ {
+ var partFile = await BuildAndSaveBoxAsync();
+ await _docs.CreateAsync(KompasDocumentType.Drawing);
+ await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150);
+ return partFile;
+ }
+
+ [Fact]
+ public async Task Angular_measures_right_angle()
+ {
+ await NewDrawingWithViewsAsync();
+ try
+ {
+ // Вершина (0,0); сторона1 → (10,0); сторона2 → (0,10); дуга в секторе (5,5) → 90°.
+ var r = await _drawing.AddAngularDimensionAsync(
+ viewNumber: 0, xc: 0, yc: 0, x1: 10, y1: 0, x2: 0, y2: 10, x3: 5, y3: 5,
+ angleType: AngleDimensionType.Min);
+ Assert.InRange(r.Value, 89.5, 90.5);
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Angular_measures_45_degrees()
+ {
+ await NewDrawingWithViewsAsync();
+ try
+ {
+ // Вершина (0,0); сторона1 → (10,0); сторона2 → (10,10) (45°); дуга (8,4).
+ var r = await _drawing.AddAngularDimensionAsync(
+ viewNumber: 0, xc: 0, yc: 0, x1: 10, y1: 0, x2: 10, y2: 10, x3: 8, y3: 4,
+ angleType: AngleDimensionType.Min);
+ Assert.InRange(r.Value, 44.5, 45.5);
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Targets_specific_view_and_increments_angular_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];
+
+ var before = await _drawing.GetViewAngularDimensionCountAsync(targetNumber);
+ var r = await _drawing.AddAngularDimensionAsync(
+ targetNumber, xc: 0, yc: 0, x1: 10, y1: 0, x2: 0, y2: 10, x3: 5, y3: 5,
+ angleType: AngleDimensionType.Min);
+ var after = await _drawing.GetViewAngularDimensionCountAsync(targetNumber);
+
+ Assert.Equal(targetNumber, r.ViewNumber);
+ Assert.Equal(before + 1, after);
+ Assert.InRange(r.Value, 89.5, 90.5);
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Angle_type_more_measures_reflex_angle()
+ {
+ await NewDrawingWithViewsAsync();
+ try
+ {
+ // Та же геометрия 90°, но angleType=more → измеряется рефлексный угол ~270°.
+ // Тип угла (min/max/more) выбирает, какой из секторов измерять; (x3,y3) лишь
+ // позиционирует размерную дугу (её ставим в рефлексный сектор для согласованности).
+ var r = await _drawing.AddAngularDimensionAsync(
+ viewNumber: 0, xc: 0, yc: 0, x1: 10, y1: 0, x2: 0, y2: 10, x3: -5, y3: -5,
+ angleType: AngleDimensionType.More);
+ Assert.InRange(r.Value, 269.0, 271.0);
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Coincident_vertex_and_side_throws()
+ {
+ await NewDrawingWithViewsAsync();
+ try
+ {
+ // Вершина совпадает с точкой стороны1 → вырождение до Add.
+ await Assert.ThrowsAsync(() =>
+ _drawing.AddAngularDimensionAsync(
+ viewNumber: 0, xc: 0, yc: 0, x1: 0, y1: 0, x2: 0, y2: 10, x3: 5, y3: 5,
+ angleType: AngleDimensionType.Min));
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Collinear_rays_rollback_leaves_count_unchanged()
+ {
+ await NewDrawingWithViewsAsync();
+ try
+ {
+ var before = await _drawing.GetViewAngularDimensionCountAsync(0);
+ // Сонаправленные лучи (0,0)→(10,0) и (0,0)→(20,0): угол ≈0 → пост-проверка → откат.
+ await Assert.ThrowsAsync(() =>
+ _drawing.AddAngularDimensionAsync(
+ viewNumber: 0, xc: 0, yc: 0, x1: 10, y1: 0, x2: 20, y2: 0, x3: 15, y3: 5,
+ angleType: AngleDimensionType.Min));
+ var after = await _drawing.GetViewAngularDimensionCountAsync(0);
+ Assert.Equal(before, after);
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task No_views_throws()
+ {
+ await _docs.CreateAsync(KompasDocumentType.Drawing);
+ try
+ {
+ await Assert.ThrowsAsync(() =>
+ _drawing.AddAngularDimensionAsync(
+ viewNumber: 0, xc: 0, yc: 0, x1: 10, y1: 0, x2: 0, y2: 10, x3: 5, y3: 5,
+ angleType: AngleDimensionType.Min));
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Unknown_view_number_throws()
+ {
+ await NewDrawingWithViewsAsync();
+ try
+ {
+ await Assert.ThrowsAsync(() =>
+ _drawing.AddAngularDimensionAsync(
+ viewNumber: 999, xc: 0, yc: 0, x1: 10, y1: 0, x2: 0, y2: 10, x3: 5, y3: 5,
+ angleType: AngleDimensionType.Min));
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+}
diff --git a/tests/Kompas.Mcp.Tests/Integration/DrawingRadialTests.cs b/tests/Kompas.Mcp.Tests/Integration/DrawingRadialTests.cs
new file mode 100644
index 0000000..f5d0ae6
--- /dev/null
+++ b/tests/Kompas.Mcp.Tests/Integration/DrawingRadialTests.cs
@@ -0,0 +1,115 @@
+using Kompas.Mcp.Core.Documents;
+using Kompas.Mcp.Core.Drawings;
+using Kompas.Mcp.Core.Modeling;
+
+namespace Kompas.Mcp.Tests.Integration;
+
+/// Интеграция: радиальный размер (R) на виде (API7 ISymbols2DContainer.RadialDimensions).
+[Trait("Category", "Integration")]
+[Collection(KompasCollection.Name)]
+public sealed class DrawingRadialTests : IntegrationTestBase
+{
+ private readonly DocumentService _docs;
+ private readonly PartModeler _modeler;
+ private readonly DrawingService _drawing;
+
+ public DrawingRadialTests(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 Radial_dimension_value_is_radius()
+ {
+ var partFile = await BuildAndSaveBoxAsync();
+ await _docs.CreateAsync(KompasDocumentType.Drawing);
+ try
+ {
+ await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150);
+ // Радиус 12 → значение радиального = радиус (НЕ диаметр), подтверждено спайком.
+ var r = await _drawing.AddRadialDimensionAsync(viewNumber: 0, xc: 10, yc: 10, radius: 12, angleDeg: 45);
+ Assert.InRange(r.Value, 11.9, 12.1);
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Targets_specific_view_and_increments_radial_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];
+
+ var before = await _drawing.GetViewRadialDimensionCountAsync(targetNumber);
+ var r = await _drawing.AddRadialDimensionAsync(targetNumber, xc: 5, yc: 5, radius: 8, angleDeg: 30);
+ var after = await _drawing.GetViewRadialDimensionCountAsync(targetNumber);
+
+ Assert.Equal(targetNumber, r.ViewNumber);
+ Assert.Equal(before + 1, after);
+ Assert.InRange(r.Value, 7.9, 8.1);
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Non_positive_radius_throws()
+ {
+ var partFile = await BuildAndSaveBoxAsync();
+ await _docs.CreateAsync(KompasDocumentType.Drawing);
+ try
+ {
+ await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150);
+ await Assert.ThrowsAsync(() =>
+ _drawing.AddRadialDimensionAsync(viewNumber: 0, xc: 0, yc: 0, radius: 0, angleDeg: 0));
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task No_views_throws()
+ {
+ await _docs.CreateAsync(KompasDocumentType.Drawing);
+ try
+ {
+ await Assert.ThrowsAsync(() =>
+ _drawing.AddRadialDimensionAsync(viewNumber: 0, xc: 0, yc: 0, radius: 10, angleDeg: 0));
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+
+ [Fact]
+ public async Task Unknown_view_number_throws()
+ {
+ var partFile = await BuildAndSaveBoxAsync();
+ await _docs.CreateAsync(KompasDocumentType.Drawing);
+ try
+ {
+ await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150);
+ await Assert.ThrowsAsync(() =>
+ _drawing.AddRadialDimensionAsync(viewNumber: 999, xc: 0, yc: 0, radius: 10, angleDeg: 0));
+ }
+ finally { await _docs.CloseAsync(save: false); }
+ }
+}