From 246233727c3b249708ef385f5b16f35c6edc4d03 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Wed, 27 May 2026 19:51:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(drawing):=20=D1=80=D0=B0=D0=B4=D0=B8=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20(R)=20=D0=B8=20=D1=83=D0=B3?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D1=8B=20=D0=BD=D0=B0=20=D0=B2=D0=B8=D0=B4=D0=B5=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D1=82=D0=B5=D0=B6=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...drawing-radial-angular-dimension-design.md | 165 ++++++++++++++++ .../Drawings/AngleDimensionType.cs | 42 +++++ .../Drawings/DrawingDimensionResult.cs | 2 +- .../Drawings/DrawingService.cs | 103 ++++++++++ src/Kompas.Mcp.Host/Tools/DrawingTools.cs | 44 +++++ .../AngleDimensionTypesTests.cs | 30 +++ .../Integration/DrawingAngularTests.cs | 178 ++++++++++++++++++ .../Integration/DrawingRadialTests.cs | 115 +++++++++++ 8 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-05-27-drawing-radial-angular-dimension-design.md create mode 100644 src/Kompas.Mcp.Core/Drawings/AngleDimensionType.cs create mode 100644 tests/Kompas.Mcp.Tests/AngleDimensionTypesTests.cs create mode 100644 tests/Kompas.Mcp.Tests/Integration/DrawingAngularTests.cs create mode 100644 tests/Kompas.Mcp.Tests/Integration/DrawingRadialTests.cs 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); } + } +}