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,44 @@
namespace Kompas.Mcp.Core.Drawings;
/// <summary>Геометрия окружности-кандидата вида (центр + радиус, локальная СК вида, мм).</summary>
public sealed record CircleCandidate(double Xc, double Yc, double Radius);
/// <summary>
/// Чистый отбор окружности проекционного вида по ключу (центр + радиус) для ассоциативной привязки
/// размера. Выделен из COM-перечисления ради unit-тестов.
/// </summary>
public static class CircularObjectMatch
{
/// <summary>
/// Индекс единственной окружности из <paramref name="candidates"/>, чей центр и радиус совпадают с
/// ключом (<paramref name="keyX"/>,<paramref name="keyY"/>,<paramref name="keyR"/>) в пределах
/// <paramref name="tol"/> (мм): <c>dist(центр,ключ) ≤ tol</c> И <c>|R keyR| ≤ tol</c>.
/// </summary>
/// <exception cref="InvalidOperationException">Нет подходящих (0) или неоднозначно (&gt;1).</exception>
public static int SelectMatchIndex(IReadOnlyList<CircleCandidate> candidates,
double keyX, double keyY, double keyR, double tol)
{
ArgumentNullException.ThrowIfNull(candidates);
var matches = new List<int>();
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}. " +
"Уточните координаты/радиус."),
};
}
}
+59 -10
View File
@@ -142,22 +142,27 @@ public sealed class DrawingService
/// Значение (диаметр = 2·radius) измеряется автоматически. Возвращает диаметр и номер вида.
/// </summary>
public Task<DrawingDimensionResult> 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
/// Значение (= радиус) измеряется автоматически. Возвращает радиус и номер вида.
/// </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);
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);
}
/// <summary>Допуск совпадения окружности с ключом поиска при ассоциативной привязке (мм).</summary>
private const double CircleMatchToleranceMm = 1.0;
/// <summary>Вид + оба контейнера (QI от одного вида): ISymbols2DContainer (создание размера) и
/// IDrawingContainer (поиск геометрии для ассоциативной привязки).</summary>
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);
}
/// <summary>Найти окружность вида по ключу (центр + радиус) и вернуть её как IDrawingObject
/// (для <c>dim.BaseObject</c>). Бросает при отсутствии/неоднозначности.</summary>
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;
}
/// <summary>Собрать окружности вида (IDrawingContainer.Circles) с их геометрией и COM-ссылкой.</summary>
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;
}
/// <summary>Вид + его ISymbols2DContainer (QI от вида). Общий для линейных/диаметральных/… размеров.</summary>
private (IView View, ISymbols2DContainer Symbols) RequireSymbols2DContainer(int viewNumber)
{
+19 -9
View File
@@ -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<string> 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<string> 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")]