feat(drawing): ассоциативная привязка диаметрального/радиального размера к окружности вида

Флаг 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 21:47:17 +03:00
parent ecabaa1828
commit 775128484e
6 changed files with 450 additions and 19 deletions
@@ -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<CircleCandidate>
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` отсутствует (нужен иной механизм). Рамка/формат листа; выноски/базы/допуски формы.