From 775128484e7d954ea8adc68c44c877f83e837f70 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Wed, 27 May 2026 21:47:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(drawing):=20=D0=B0=D1=81=D1=81=D0=BE=D1=86?= =?UTF-8?q?=D0=B8=D0=B0=D1=82=D0=B8=D0=B2=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D0=B0=20=D0=B4=D0=B8=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE/?= =?UTF-8?q?=D1=80=D0=B0=D0=B4=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=80=D0=B0=20=D0=BA=20?= =?UTF-8?q?=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Флаг associate у drawing_add_diametral_dimension/drawing_add_radial_dimension: - associate=false (по умолчанию) — прежний свободный размер (без изменений); - associate=true — (xc,yc,radius) как ключ поиска спроецированной окружности (IDrawingContainer.Circles), привязка через BaseObject; значение Ø/R читается из геометрии и обновляется с моделью. - CircularObjectMatch.SelectMatchIndex (чистый отбор по центру+радиусу, допуск 1 мм; бросает при отсутствии/неоднозначности; концентрические разделяются радиусом) - RequireViewContainers (вид → ISymbols2DContainer + IDrawingContainer), CollectCircles, FindBaseCircle - 5 unit + 6 интеграционных тестов (всего 278 зелёных), сборка Release чистая Спайк подтвердил: BaseObject=circle без задания Xc/Yc/Radius → Valid, NominalValue из геометрии (Ø=20/R=10 для R10). Объём — только окружности (Circles); дуги/угловой/линейный — далее. Ревью Codex спека учтено (оба контейнера, guard вокруг блока, доказательство ассоциативности тестом radius=10.3→Ø20, регресс обоих свободных путей). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...27-drawing-associative-dimension-design.md | 132 ++++++++++++++++ .../Drawings/CircularObjectMatch.cs | 44 ++++++ .../Drawings/DrawingService.cs | 69 +++++++-- src/Kompas.Mcp.Host/Tools/DrawingTools.cs | 28 ++-- .../CircularObjectMatchTests.cs | 53 +++++++ .../DrawingAssociativeDimensionTests.cs | 143 ++++++++++++++++++ 6 files changed, 450 insertions(+), 19 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-27-drawing-associative-dimension-design.md create mode 100644 src/Kompas.Mcp.Core/Drawings/CircularObjectMatch.cs create mode 100644 tests/Kompas.Mcp.Tests/CircularObjectMatchTests.cs create mode 100644 tests/Kompas.Mcp.Tests/Integration/DrawingAssociativeDimensionTests.cs diff --git a/docs/superpowers/specs/2026-05-27-drawing-associative-dimension-design.md b/docs/superpowers/specs/2026-05-27-drawing-associative-dimension-design.md new file mode 100644 index 0000000..926775f --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-drawing-associative-dimension-design.md @@ -0,0 +1,132 @@ +# Дизайн: ассоциативная привязка диаметрального и радиального размеров к геометрии вида (API7) + +**Дата:** 2026-05-27 +**Статус:** дизайн согласован (вариант A — флаг `associate`), спайк проведён, ревью Codex спека учтено — к реализации + +## Правки по ревью Codex (спек) + +- **#3/#4** объём сужен до **окружностей** (`IDrawingContainer.Circles`): спайк подтвердил привязку + только к окружности. Дуги (`Arcs`) — следующий инкремент со своим спайком (привязка `IArc` как + `BaseObject` для диам./радиального не проверена; дуги одного родителя неотличимы по центру+радиусу). +- **#1** вид резолвится один раз и приводится к обоим контейнерам: `ISymbols2DContainer` (создание + размера) И `IDrawingContainer` (поиск окружности). Новый helper `RequireViewContainers(viewNumber)` + → `(IView, ISymbols2DContainer, IDrawingContainer)`. +- **#2** весь блок после `…Dimensions.Add()` (присвоение `BaseObject`/`Angle`/`AutoNominalValue`, + `Update`, `Valid`, read-back) — внутри того же `try/catch` с `Delete` при любом сбое (как свободный путь). +- **#5** тест доказывает ассоциативность: ключ поиска `radius=10.3` (в допуске находит окружность R10) + → associate вернёт Ø=20 (из геометрии), свободный дал бы 2·10.3=20.6; `Value≈20` (не 20.6) доказывает + чтение из геометрии. + диагностический read-back `BaseObject != null`. +- **#6** обе ветки (свободная/associate) конвертируют `angle` через `DimensionAngles.ToRadians(angleDeg)` + перед присвоением `dim.Angle`. +- **#7** пост-инвариант значения (`value > 0 && double.IsFinite`) сохраняется и в associate-ветке (иначе откат). +- **#8** регресс-тесты свободного пути (`associate=false`) для ОБОИХ — диаметрального и радиального. + +## Цель + +Веха 2D-ЧЕРТЁЖ, инкремент 7. Размеры пока «свободные» (значение из заданных координат). Ассоциативная +привязка делает диаметральный/радиальный размер **связанным с проекцией геометрии**: значение (Ø/R) +читается из спроецированной окружности/дуги вида через `BaseObject` и обновляется вместе с моделью. +Первый шаг к «умному» чертежу. + +## Объём (обоснован рефлексией interop) + +Только **диаметральный** и **радиальный**, привязка только к **окружности** (`IDrawingContainer.Circles`) +— у `IDiametralDimension`/`IRadialDimension` есть свойство `BaseObject`; `_Circle` реализует +`IDrawingObject`, поэтому объект из коллекции присваивается `dim.BaseObject` (подтверждено спайком). +У `ILineDimension` `BaseObject` НЕТ; угловой требует `BaseObject1/2`; привязка к дуге (`Arcs`) не +проверена — всё это отдельные инкременты (вне объёма). + +## Спайк: механизм подтверждён вживую (НЕ разучивать) + +Спайк (`_SpikeAssocDim`, прогнан на реальном КОМПАС, затем удалён): цилиндр (окружность R10, выдавлен +на 20) → стандартные виды. Один из видов содержит спроецированную окружность. + +``` +IDrawingContainer dc = (IDrawingContainer)view; // QI от вида (как ISymbols2DContainer) +ICircles circles = dc.Circles; // Count=1; circles[0] as ICircle → Xc=0,Yc=0,R=10 +IDiametralDimension dia = symbols.DiametralDimensions.Add(); +dia.BaseObject = circles[0]; // IDrawingObject; Xc/Yc/Radius НЕ задаём +dia.Angle = π/4; // направление выноски (размещение) +dia.Update(); // True, Valid=True +double v = ((IDimensionText)dia).NominalValue; // == 20 — ДИАМЕТР, прочитан из геометрии! +``` + +**Проверено:** `BaseObject=circle` без задания координат → `Update=True`, `Valid=True`, +диаметральный `NominalValue=20` (=2·R геометрии), радиальный `NominalValue=10` (=R). `BaseObject` +остаётся непустым после. Геометрия вида: `IDrawingContainer.Circles` (`ICircle`: `Xc/Yc/Radius`), +`.Arcs` (`IArc`: `Xc/Yc/Radius/Angle1/Angle2`), `.LineSegments`. **Нет** «find by point» — выбор +перебором коллекции. + +## MCP-инструменты (расширение существующих, вариант A) + +К `drawing_add_diametral_dimension` и `drawing_add_radial_dimension` добавляется параметр +`associate=false`: + +| `associate` | Поведение | +|---|---| +| `false` (по умолчанию) | Текущее «свободное»: `Xc/Yc/Radius` задаются напрямую (без изменений, обратная совместимость). | +| `true` | `(xc,yc,radius)` — **ключ поиска**: найти в виде окружность/дугу с центром ≈ (xc,yc) и радиусом ≈ radius, привязать `BaseObject`; значение (Ø/R) читается из геометрии. | + +Координаты ключа — view-local (= координаты модели при масштабе 1:1; при ином масштабе — масштабированные, +как у прочих размеров). `angle` — направление выноски (°), как сейчас. `viewNumber` — как сейчас. + +## Выбор объекта + +В `IDrawingContainer` нет «find by point». Перебираем `Circles`, собираем кандидатов `(Xc, Yc, Radius)`, +ищем подходящий по ключу `(xc, yc, radius)`: +- кандидат подходит, если `dist(центр, ключ) ≤ tol` И `|R − radius| ≤ tol` (`tol = 1.0 мм`, константа); +- ровно 1 подходящий → привязываем; 0 → ошибка «окружность не найдена рядом с (xc,yc) R≈radius»; + >1 → ошибка «неоднозначно — уточните координаты/радиус». + +Радиус в ключе снимает неоднозначность концентрических окружностей (цековка R5/R10 — ключ R10 +выберет только внешнюю). Логика отбора — чистая (unit-тест), перечисление COM — в сервисе. + +## Архитектура + +- `DrawingService.AddDiametralDimensionAsync(viewNumber, xc, yc, radius, angleDeg, associate, ct)` и + `AddRadialDimensionAsync(..., associate, ct)` — добавлен `associate` (по умолчанию `false`). При `false` — + текущая ветка без изменений. Обе ветки: `angleRad = DimensionAngles.ToRadians(angleDeg)`. +- При `associate=true`: `RequireViewContainers(viewNumber)` → собрать кандидатов из `Circles` + (`CollectCircles`) → `CircularObjectMatch.SelectMatchIndex(...)` → взять `IDrawingObject` по индексу → + `symbols.{Diametral|Radial}Dimensions.Add()` → **внутри `try/catch`+`Delete`**: `dim.BaseObject = obj`, + `dim.Angle = angleRad`, `AutoNominalValue=true` → `Update()` (FALSE→откат) → `Valid` (false→откат) → + `value = NominalValue` (из геометрии); `value<=0 || !IsFinite` → откат → `DrawingDimensionResult`. +- Новый helper `RequireViewContainers(viewNumber)` → `(IView View, ISymbols2DContainer Symbols, + IDrawingContainer Drawing)` — резолвит вид один раз, оба QI с раздельной диагностикой (переиспользует + `FindView`). +- Новый файл `src/Kompas.Mcp.Core/Drawings/CircularObjectMatch.cs`: чистый статический + `record CircleCandidate(double Xc, double Yc, double Radius)` + `int SelectMatchIndex(IReadOnlyList + candidates, double keyX, double keyY, double keyR, double tol)` — возвращает индекс единственного + совпадения; `InvalidOperationException` при 0 (нет) / >1 (неоднозначно). +- Сбор кандидатов из COM (Circles в список с сохранением ссылки на `IDrawingObject` + геометрии) — в + сервисе (private helper `CollectCircles(IDrawingContainer)` → `List<(IDrawingObject Obj, CircleCandidate Geom)>`). +- `DrawingDimensionResult` переиспользуется (значение из геометрии). Свободный путь не трогаем. + +## Валидация + +- Оба пути: `RequireFiniteCoords(xc, yc, angleDeg)` + `RequirePositiveRadius(radius)` (есть). В + associate-режиме radius — положительный ключ поиска. +- `CircularObjectMatch.SelectMatchIndex` — чистая логика отбора (unit). + +## Тестирование + +### Unit (`CircularObjectMatchTests`) +- Единственное совпадение по центру+радиусу → индекс. +- Нет кандидатов в допуске → `InvalidOperationException` (нет). +- Два совпадения в допуске → `InvalidOperationException` (неоднозначно). +- Концентрические (один центр, разный R): ключ по R выбирает нужный (не неоднозначно). +- Пустой список → `InvalidOperationException`. + +### Integration (`DrawingAssociativeDimensionTests`; модель — цилиндр R10, как в спайке) +1. **Диаметральный associative + доказательство геометрии**: ключ `(0,0,10.3)` (radius в допуске + находит окружность R10) → `Value ≈ 20` (Ø из геометрии, НЕ 2·10.3=20.6 — доказывает чтение из + геометрии); попадание в вид (`DiametralDimensions.Count` +1). +2. **Радиальный associative**: `(0,0,10.3)` → `Value ≈ 10` (R из геометрии, не 10.3). +3. **Нет окружности рядом**: ключ далеко от геометрии → `InvalidOperationException`; счётчик не вырос (откат). +4. **Свободный путь не сломан** — диаметральный `associate=false` `(xc,yc,15)` → `Value ≈ 30`. +5. **Свободный путь не сломан** — радиальный `associate=false` `(xc,yc,12)` → `Value ≈ 12` (регресс обоих). +6. **Нет видов** (associate) → понятная ошибка. + +## Дальнейшее (вне спека) +- Ассоциативная привязка углового (`BaseObject1/2` — пара отрезков) и шероховатости (`IRough.BaseObject`); + линейный — `BaseObject` отсутствует (нужен иной механизм). Рамка/формат листа; выноски/базы/допуски формы. diff --git a/src/Kompas.Mcp.Core/Drawings/CircularObjectMatch.cs b/src/Kompas.Mcp.Core/Drawings/CircularObjectMatch.cs new file mode 100644 index 0000000..e9bc09f --- /dev/null +++ b/src/Kompas.Mcp.Core/Drawings/CircularObjectMatch.cs @@ -0,0 +1,44 @@ +namespace Kompas.Mcp.Core.Drawings; + +/// Геометрия окружности-кандидата вида (центр + радиус, локальная СК вида, мм). +public sealed record CircleCandidate(double Xc, double Yc, double Radius); + +/// +/// Чистый отбор окружности проекционного вида по ключу (центр + радиус) для ассоциативной привязки +/// размера. Выделен из COM-перечисления ради unit-тестов. +/// +public static class CircularObjectMatch +{ + /// + /// Индекс единственной окружности из , чей центр и радиус совпадают с + /// ключом (,,) в пределах + /// (мм): dist(центр,ключ) ≤ tol И |R − keyR| ≤ tol. + /// + /// Нет подходящих (0) или неоднозначно (>1). + public static int SelectMatchIndex(IReadOnlyList candidates, + double keyX, double keyY, double keyR, double tol) + { + ArgumentNullException.ThrowIfNull(candidates); + + var matches = new List(); + for (var i = 0; i < candidates.Count; i++) + { + var c = candidates[i]; + var dx = c.Xc - keyX; + var dy = c.Yc - keyY; + if (Math.Sqrt(dx * dx + dy * dy) <= tol && Math.Abs(c.Radius - keyR) <= tol) + matches.Add(i); + } + + return matches.Count switch + { + 1 => matches[0], + 0 => throw new InvalidOperationException( + $"Окружность не найдена рядом с ({keyX},{keyY}) R≈{keyR} (допуск {tol} мм). " + + "Проверьте координаты/радиус и наличие окружности на этом виде."), + _ => throw new InvalidOperationException( + $"Неоднозначный выбор окружности у ({keyX},{keyY}) R≈{keyR}: подходит {matches.Count}. " + + "Уточните координаты/радиус."), + }; + } +} diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingService.cs b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs index 00d0224..3a3eb4d 100644 --- a/src/Kompas.Mcp.Core/Drawings/DrawingService.cs +++ b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs @@ -142,22 +142,27 @@ public sealed class DrawingService /// Значение (диаметр = 2·radius) измеряется автоматически. Возвращает диаметр и номер вида. /// public Task AddDiametralDimensionAsync(int viewNumber, double xc, double yc, - double radius, double angleDeg, CancellationToken ct = default) - => _dispatcher.InvokeAsync(() => AddDiametralDimensionCore(viewNumber, xc, yc, radius, angleDeg), ct); + double radius, double angleDeg, bool associate = false, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => AddDiametralDimensionCore(viewNumber, xc, yc, radius, angleDeg, associate), ct); private DrawingDimensionResult AddDiametralDimensionCore(int viewNumber, double xc, double yc, - double radius, double angleDeg) + double radius, double angleDeg, bool associate) { DrawingValidation.RequireFiniteCoords(xc, yc); DrawingValidation.RequirePositiveRadius(radius); var angleRad = DimensionAngles.ToRadians(angleDeg); - var (view, symbols) = RequireSymbols2DContainer(viewNumber); + var (view, symbols, drawing) = RequireViewContainers(viewNumber); + var baseObject = associate ? FindBaseCircle(drawing, xc, yc, radius) : null; var dim = symbols.DiametralDimensions?.Add() ?? throw new InvalidOperationException("DiametralDimensions.Add вернул null."); try { - dim.Xc = xc; dim.Yc = yc; dim.Radius = radius; dim.Angle = angleRad; + if (baseObject != null) + dim.BaseObject = baseObject; // ассоциативно: значение Ø читается из геометрии + else + { dim.Xc = xc; dim.Yc = yc; dim.Radius = radius; } + dim.Angle = angleRad; // DimensionType (стиль размерной линии) не задаём — оставляем COM-дефолт (полярность bool // не подтверждена; дефолт даёт валидный размер с верным значением — проверено спайком). ((IDimensionText)dim).AutoNominalValue = true; @@ -190,22 +195,27 @@ public sealed class DrawingService /// Значение (= радиус) измеряется автоматически. Возвращает радиус и номер вида. /// 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); + double radius, double angleDeg, bool associate = false, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => AddRadialDimensionCore(viewNumber, xc, yc, radius, angleDeg, associate), ct); private DrawingDimensionResult AddRadialDimensionCore(int viewNumber, double xc, double yc, - double radius, double angleDeg) + double radius, double angleDeg, bool associate) { DrawingValidation.RequireFiniteCoords(xc, yc); DrawingValidation.RequirePositiveRadius(radius); var angleRad = DimensionAngles.ToRadians(angleDeg); - var (view, symbols) = RequireSymbols2DContainer(viewNumber); + var (view, symbols, drawing) = RequireViewContainers(viewNumber); + var baseObject = associate ? FindBaseCircle(drawing, xc, yc, radius) : null; var dim = symbols.RadialDimensions?.Add() ?? throw new InvalidOperationException("RadialDimensions.Add вернул null."); try { - dim.Xc = xc; dim.Yc = yc; dim.Radius = radius; dim.Angle = angleRad; + if (baseObject != null) + dim.BaseObject = baseObject; // ассоциативно: значение R читается из геометрии + else + { dim.Xc = xc; dim.Yc = yc; dim.Radius = radius; } + dim.Angle = angleRad; dim.DimensionType = true; // стиль R «от центра» (на величину не влияет — спайк) ((IDimensionText)dim).AutoNominalValue = true; @@ -429,6 +439,45 @@ public sealed class DrawingService return (view, container); } + /// Допуск совпадения окружности с ключом поиска при ассоциативной привязке (мм). + private const double CircleMatchToleranceMm = 1.0; + + /// Вид + оба контейнера (QI от одного вида): ISymbols2DContainer (создание размера) и + /// IDrawingContainer (поиск геометрии для ассоциативной привязки). + private (IView View, ISymbols2DContainer Symbols, IDrawingContainer Drawing) RequireViewContainers(int viewNumber) + { + var view = FindView(viewNumber); + var symbols = view as ISymbols2DContainer + ?? throw new InvalidOperationException("Вид не приводится к ISymbols2DContainer."); + var drawing = view as IDrawingContainer + ?? throw new InvalidOperationException("Вид не приводится к IDrawingContainer."); + return (view, symbols, drawing); + } + + /// Найти окружность вида по ключу (центр + радиус) и вернуть её как IDrawingObject + /// (для dim.BaseObject). Бросает при отсутствии/неоднозначности. + private static IDrawingObject FindBaseCircle(IDrawingContainer drawing, double xc, double yc, double radius) + { + var items = CollectCircles(drawing); + var index = CircularObjectMatch.SelectMatchIndex( + items.Select(i => i.Geom).ToList(), xc, yc, radius, CircleMatchToleranceMm); + return items[index].Obj; + } + + /// Собрать окружности вида (IDrawingContainer.Circles) с их геометрией и COM-ссылкой. + private static List<(IDrawingObject Obj, CircleCandidate Geom)> CollectCircles(IDrawingContainer drawing) + { + var result = new List<(IDrawingObject, CircleCandidate)>(); + var circles = drawing.Circles; + if (circles == null) return result; + for (var i = 0; i < circles.Count; i++) + { + if (circles[i] is not ICircle c) continue; + result.Add(((IDrawingObject)c, new CircleCandidate(c.Xc, c.Yc, c.Radius))); + } + return result; + } + /// Вид + его 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 a729a70..966a8d0 100644 --- a/src/Kompas.Mcp.Host/Tools/DrawingTools.cs +++ b/src/Kompas.Mcp.Host/Tools/DrawingTools.cs @@ -71,35 +71,45 @@ public sealed class DrawingTools(KompasSession session, DrawingService drawing) "задаётся центром (xc,yc) и радиусом radius в ЛОКАЛЬНОЙ СК вида (мм; при масштабе 1:1 = координаты " + "модели на плоскости вида) — задайте параметры реальной окружности вида. angle — направление выноски " + "в градусах. viewNumber — номер вида из drawing_create_standard_views (0 = первый/главный). " + - "Значение (диаметр = 2·radius) измеряется автоматически. Возвращает диаметр и номер вида.")] + "associate=false: свободный размер (значение = 2·radius). associate=true: АССОЦИАТИВНО — (xc,yc,radius) " + + "используются как ключ для поиска спроецированной окружности на виде; размер привязывается к ней " + + "(BaseObject) и значение Ø читается из геометрии и обновляется с моделью. Значение измеряется " + + "автоматически. Возвращает диаметр и номер вида.")] public async Task AddDiametralDimension( [Description("X центра окружности (СК вида, мм)")] double xc, [Description("Y центра окружности")] double yc, [Description("Радиус окружности, мм")] double radius, [Description("Направление выноски, градусы")] double angle = 0, - [Description("Номер вида (0 = первый/главный)")] int viewNumber = 0) + [Description("Номер вида (0 = первый/главный)")] int viewNumber = 0, + [Description("true = привязать к окружности вида (значение из геометрии); false = свободный размер")] bool associate = false) { await session.ConnectAsync(); - var r = await drawing.AddDiametralDimensionAsync(viewNumber, xc, yc, radius, angle); - return $"Диаметральный размер поставлен на виде {r.ViewNumber}, Ø{r.Value:0.###} мм."; + var r = await drawing.AddDiametralDimensionAsync(viewNumber, xc, yc, radius, angle, associate); + var mode = associate ? " (ассоциативно)" : ""; + return $"Диаметральный размер поставлен на виде {r.ViewNumber}, Ø{r.Value:0.###} мм{mode}."; } [McpServerTool(Name = "drawing_add_radial_dimension")] - [Description("Поставить радиальный размер (R) окружности/дуги на виде активного чертежа. Окружность " + + [Description("Поставить радиальный размер (R) окружности на виде активного чертежа. Окружность " + "задаётся центром (xc,yc) и радиусом radius в ЛОКАЛЬНОЙ СК вида (мм; при масштабе 1:1 = координаты " + "модели на плоскости вида) — задайте параметры реальной окружности вида. angle — направление выноски " + "в градусах. viewNumber — номер вида из drawing_create_standard_views (0 = первый/главный). " + - "Значение (= радиус) измеряется автоматически. Возвращает радиус и номер вида.")] + "associate=false: свободный размер (значение = radius). associate=true: АССОЦИАТИВНО — (xc,yc,radius) " + + "используются как ключ для поиска спроецированной окружности на виде; размер привязывается к ней " + + "(BaseObject) и значение R читается из геометрии и обновляется с моделью. Значение измеряется " + + "автоматически. Возвращает радиус и номер вида.")] public async Task AddRadialDimension( [Description("X центра окружности (СК вида, мм)")] double xc, [Description("Y центра окружности")] double yc, [Description("Радиус окружности, мм")] double radius, [Description("Направление выноски, градусы")] double angle = 0, - [Description("Номер вида (0 = первый/главный)")] int viewNumber = 0) + [Description("Номер вида (0 = первый/главный)")] int viewNumber = 0, + [Description("true = привязать к окружности вида (значение из геометрии); false = свободный размер")] bool associate = false) { await session.ConnectAsync(); - var r = await drawing.AddRadialDimensionAsync(viewNumber, xc, yc, radius, angle); - return $"Радиальный размер поставлен на виде {r.ViewNumber}, R{r.Value:0.###} мм."; + var r = await drawing.AddRadialDimensionAsync(viewNumber, xc, yc, radius, angle, associate); + var mode = associate ? " (ассоциативно)" : ""; + return $"Радиальный размер поставлен на виде {r.ViewNumber}, R{r.Value:0.###} мм{mode}."; } [McpServerTool(Name = "drawing_add_angular_dimension")] diff --git a/tests/Kompas.Mcp.Tests/CircularObjectMatchTests.cs b/tests/Kompas.Mcp.Tests/CircularObjectMatchTests.cs new file mode 100644 index 0000000..c2e960f --- /dev/null +++ b/tests/Kompas.Mcp.Tests/CircularObjectMatchTests.cs @@ -0,0 +1,53 @@ +using Kompas.Mcp.Core.Drawings; + +namespace Kompas.Mcp.Tests; + +[Trait("Category", "Unit")] +public sealed class CircularObjectMatchTests +{ + private const double Tol = 1.0; + + private static IReadOnlyList Candidates(params (double x, double y, double r)[] items) + => items.Select(i => new CircleCandidate(i.x, i.y, i.r)).ToList(); + + [Fact] + public void SelectMatchIndex_returns_unique_match() + { + var c = Candidates((0, 0, 10), (50, 20, 5)); + // ключ с допустимым отклонением центра и радиуса + Assert.Equal(0, CircularObjectMatch.SelectMatchIndex(c, 0.3, -0.2, 10.3, Tol)); + Assert.Equal(1, CircularObjectMatch.SelectMatchIndex(c, 50, 20, 5, Tol)); + } + + [Fact] + public void SelectMatchIndex_concentric_disambiguated_by_radius() + { + // концентрические R5 и R10 (цековка): ключ R10 выбирает внешнюю, не неоднозначно + var c = Candidates((0, 0, 5), (0, 0, 10)); + Assert.Equal(1, CircularObjectMatch.SelectMatchIndex(c, 0, 0, 10, Tol)); + Assert.Equal(0, CircularObjectMatch.SelectMatchIndex(c, 0, 0, 5, Tol)); + } + + [Fact] + public void SelectMatchIndex_throws_when_none_in_tolerance() + { + var c = Candidates((0, 0, 10)); + // центр далеко + Assert.Throws(() => CircularObjectMatch.SelectMatchIndex(c, 100, 100, 10, Tol)); + // радиус далеко + Assert.Throws(() => CircularObjectMatch.SelectMatchIndex(c, 0, 0, 25, Tol)); + } + + [Fact] + public void SelectMatchIndex_throws_when_ambiguous() + { + // две почти совпадающие окружности в допуске + var c = Candidates((0, 0, 10), (0.2, 0.2, 10.2)); + Assert.Throws(() => CircularObjectMatch.SelectMatchIndex(c, 0, 0, 10, Tol)); + } + + [Fact] + public void SelectMatchIndex_throws_on_empty() + => Assert.Throws( + () => CircularObjectMatch.SelectMatchIndex(Candidates(), 0, 0, 10, Tol)); +} diff --git a/tests/Kompas.Mcp.Tests/Integration/DrawingAssociativeDimensionTests.cs b/tests/Kompas.Mcp.Tests/Integration/DrawingAssociativeDimensionTests.cs new file mode 100644 index 0000000..cb834b3 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/DrawingAssociativeDimensionTests.cs @@ -0,0 +1,143 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Drawings; +using Kompas.Mcp.Core.Modeling; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: ассоциативная привязка диаметрального/радиального размера к окружности вида +/// (BaseObject). Модель — цилиндр R10 (вид проецирует окружность). +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class DrawingAssociativeDimensionTests : IntegrationTestBase +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly DrawingService _drawing; + + public DrawingAssociativeDimensionTests(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 BuildAndSaveCylinderAsync() + { + var path = TestPaths.NewFile(".m3d"); + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddCircleAsync(s, 0, 0, 10); + 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 Diametral_associative_reads_diameter_from_geometry() + { + var partFile = await BuildAndSaveCylinderAsync(); + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + var views = await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150); + var target = views.ViewNumbers[0]; // вид с окружностью (спайк) + + var before = await _drawing.GetViewDiametralDimensionCountAsync(target); + // Ключ radius=10.3 (в допуске 1.0 находит окружность R10). Ассоциативно → Ø=20 из геометрии, + // а НЕ 2·10.3=20.6 → доказывает чтение значения из геометрии, а не из входа. + var r = await _drawing.AddDiametralDimensionAsync(target, xc: 0, yc: 0, radius: 10.3, + angleDeg: 45, associate: true); + var after = await _drawing.GetViewDiametralDimensionCountAsync(target); + + Assert.Equal(target, r.ViewNumber); + Assert.InRange(r.Value, 19.9, 20.1); // Ø=20 из геометрии (не 20.6) + Assert.Equal(before + 1, after); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Radial_associative_reads_radius_from_geometry() + { + var partFile = await BuildAndSaveCylinderAsync(); + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + var views = await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150); + var target = views.ViewNumbers[0]; + var r = await _drawing.AddRadialDimensionAsync(target, xc: 0, yc: 0, radius: 10.3, + angleDeg: -45, associate: true); + Assert.InRange(r.Value, 9.9, 10.1); // R=10 из геометрии (не 10.3) + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Associative_no_circle_near_throws_and_no_orphan() + { + var partFile = await BuildAndSaveCylinderAsync(); + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + var views = await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150); + var target = views.ViewNumbers[0]; + var before = await _drawing.GetViewDiametralDimensionCountAsync(target); + // Ключ далеко от любой окружности → отбор бросит до Add (размер не создаётся). + await Assert.ThrowsAsync(() => + _drawing.AddDiametralDimensionAsync(target, xc: 1000, yc: 1000, radius: 5, + angleDeg: 0, associate: true)); + var after = await _drawing.GetViewDiametralDimensionCountAsync(target); + Assert.Equal(before, after); // нет «осиротевшего» размера + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Free_diametral_path_unchanged() + { + var partFile = await BuildAndSaveCylinderAsync(); + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + var views = await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150); + // associate=false (по умолчанию) — свободный размер: значение = 2·radius. + var r = await _drawing.AddDiametralDimensionAsync(views.ViewNumbers[0], xc: 0, yc: 0, + radius: 15, angleDeg: 0, associate: false); + Assert.InRange(r.Value, 29.9, 30.1); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Free_radial_path_unchanged() + { + var partFile = await BuildAndSaveCylinderAsync(); + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + var views = await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150); + var r = await _drawing.AddRadialDimensionAsync(views.ViewNumbers[0], xc: 0, yc: 0, + radius: 12, angleDeg: 0, associate: false); + Assert.InRange(r.Value, 11.9, 12.1); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Associative_no_views_throws() + { + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + await Assert.ThrowsAsync(() => + _drawing.AddDiametralDimensionAsync(0, xc: 0, yc: 0, radius: 10, angleDeg: 0, associate: true)); + } + finally { await _docs.CloseAsync(save: false); } + } +}