feat(drawing): радиальный (R) и угловой размеры на виде чертежа

drawing_add_radial_dimension (R окружности/дуги; NominalValue = радиус) и
drawing_add_angular_dimension (угол между двумя сторонами; angleType min/max/more
выбирает измеряемый сектор) через API7 ISymbols2DContainer. Завершают базовое
семейство размеров чертежа (после линейного и диаметрального).

- DrawingService: AddRadialDimensionAsync/AddAngularDimensionAsync + счётчики
- AngleDimensionType (enum + Parse/ToKompas, по образцу DimensionOrientations)
- DrawingDimensionResult.Value — единице-нейтральный (мм / градусы)
- 9 unit + 13 интеграционных тестов (всего 240 зелёных)

Спайк подтвердил: радиальный NominalValue = РАДИУС (не диаметр, вопреки справке);
у углового измеряемый угол выбирает angleType (min=90°/more=270°), а (x3,y3) лишь
позиционирует дугу. Спек и ревью Codex — docs/superpowers/specs/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 19:51:10 +03:00
parent 93a67d318e
commit 246233727c
8 changed files with 678 additions and 1 deletions
@@ -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`).
@@ -0,0 +1,42 @@
using Kompas6Constants;
namespace Kompas.Mcp.Core.Drawings;
/// <summary>Тип углового размера — какой из двух секторов измерять
/// (обёртка над <see cref="ksAngleDimTypeEnum"/>).</summary>
public enum AngleDimensionType
{
/// <summary>Минимальный (острый) угол.</summary>
Min,
/// <summary>Максимальный (тупой) угол.</summary>
Max,
/// <summary>Угол более 180° (рефлексный).</summary>
More,
}
/// <summary>Маппинг типа углового размера ↔ перечисление КОМПАС и разбор строки.</summary>
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, "Неизвестный тип углового размера."),
};
/// <summary>Разобрать строку типа углового размера (англ./рус.) в <see cref="AngleDimensionType"/>.</summary>
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)),
};
}
}
@@ -3,7 +3,7 @@ namespace Kompas.Mcp.Core.Drawings;
/// <summary>Результат простановки размера на чертеже.</summary>
public sealed record DrawingDimensionResult
{
/// <summary>Измеренное значение размера (мм).</summary>
/// <summary>Измеренное значение размера: линейный/диаметральный/радиальный — мм, угловой — градусы.</summary>
public required double Value { get; init; }
/// <summary>Номер вида, на котором поставлен размер.</summary>
@@ -184,6 +184,109 @@ public sealed class DrawingService
public Task<int> GetViewDiametralDimensionCountAsync(int viewNumber, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => RequireSymbols2DContainer(viewNumber).Symbols.DiametralDimensions?.Count ?? 0, ct);
/// <summary>
/// Поставить радиальный размер (R) окружности/дуги с центром (xc,yc) и радиусом <paramref name="radius"/>
/// (ЛОКАЛЬНАЯ СК вида, мм) на виде. <paramref name="angleDeg"/> — направление выноски (градусы).
/// Значение (= радиус) измеряется автоматически. Возвращает радиус и номер вида.
/// </summary>
public Task<DrawingDimensionResult> 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;
}
}
/// <summary>Число радиальных размеров на виде (для проверки попадания в вид — они в
/// ISymbols2DContainer, а не в IView.ObjectCount).</summary>
public Task<int> GetViewRadialDimensionCountAsync(int viewNumber, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => RequireSymbols2DContainer(viewNumber).Symbols.RadialDimensions?.Count ?? 0, ct);
/// <summary>
/// Поставить угловой размер между лучами (xc,yc)→(x1,y1) и (xc,yc)→(x2,y2) (ЛОКАЛЬНАЯ СК вида, мм)
/// на виде. (x3,y3) — положение размерной дуги (задаёт её радиус = расстояние от вершины).
/// <paramref name="angleType"/> выбирает, какой из двух углов измерять (min/max/more) — например
/// 90° или 270° для перпендикуляра. Значение (градусы) измеряется автоматически. Возвращает угол (°) и номер вида.
/// </summary>
public Task<DrawingDimensionResult> 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;
}
}
/// <summary>Число угловых размеров на виде (для проверки попадания в вид — они в
/// ISymbols2DContainer, а не в IView.ObjectCount).</summary>
public Task<int> GetViewAngularDimensionCountAsync(int viewNumber, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => RequireSymbols2DContainer(viewNumber).Symbols.AngleDimensions?.Count ?? 0, ct);
/// <summary>Вид + его ISymbols2DContainer (QI от вида). Общий для линейных/диаметральных/… размеров.</summary>
private (IView View, ISymbols2DContainer Symbols) RequireSymbols2DContainer(int viewNumber)
{
+44
View File
@@ -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<string> 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<string> 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.###}°.";
}
}
@@ -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<ArgumentException>(() => AngleDimensionTypes.Parse("acute"));
[Fact]
public void Parse_throws_on_null()
=> Assert.Throws<ArgumentNullException>(() => AngleDimensionTypes.Parse(null!));
// ToKompas (→ ksAngleDimTypeEnum) намеренно НЕ покрыт unit-тестом: тестовый проект не ссылается
// на Kompas6Constants (граница проекта, как у DimensionOrientations.ToKompas). Маппинг тривиален
// и проверяется интеграцией (min → острый угол 90°, выбор сектора).
}
@@ -0,0 +1,178 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Drawings;
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>Интеграция: угловой размер на виде (API7 ISymbols2DContainer.AngleDimensions).</summary>
[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<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;
}
private async Task<string> 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<ArgumentException>(() =>
_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<InvalidOperationException>(() =>
_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<InvalidOperationException>(() =>
_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<InvalidOperationException>(() =>
_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); }
}
}
@@ -0,0 +1,115 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Drawings;
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>Интеграция: радиальный размер (R) на виде (API7 ISymbols2DContainer.RadialDimensions).</summary>
[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<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 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<ArgumentOutOfRangeException>(() =>
_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<InvalidOperationException>(() =>
_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<InvalidOperationException>(() =>
_drawing.AddRadialDimensionAsync(viewNumber: 999, xc: 0, yc: 0, radius: 10, angleDeg: 0));
}
finally { await _docs.CloseAsync(save: false); }
}
}