From 910d120c8ab0f9c793327268dacdcf323ef7cda1 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Wed, 27 May 2026 18:30:04 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20drawing=5Fadd=5Fdiametral=5Fdimension?= =?UTF-8?q?=20=E2=80=94=20=D0=B4=D0=B8=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=20=D0=BD=D0=B0=20=D0=B2=D0=B8=D0=B4=D0=B5=20(API7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Веха 2D-ЧЕРТЁЖ, инкремент 4. DrawingService.AddDiametralDimensionAsync: (ISymbols2DContainer)view.DiametralDimensions.Add → Xc/Yc/Radius/Angle(рад) + AutoNominalValue → Update → Valid → NominalValue (= диаметр 2R). angle в градусах (DimensionAngles.ToRadians). Новый инструмент drawing_add_diametral_dimension. Рефактор по ревью Codex: извлечён RequireSymbols2DContainer(viewNumber) (общий для линейных/диаметральных размеров); AutoNominalValue выставляется явно (как у линейного); DimensionType — COM-дефолт; ToRadians — чистый helper с unit-тестом. Валидаторы: RequirePositiveRadius (новый). Тесты: +10 unit (ToRadians, RequirePositiveRadius), +4 integration (DrawingDiametralTests: диаметр=2R, адресация вида, radius<=0, нет видов). Итого 141 unit + 76 integration = 217. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...5-27-drawing-diametral-dimension-design.md | 17 ++- .../Drawings/DimensionOrientation.cs | 12 +++ .../Drawings/DrawingService.cs | 64 +++++++++-- .../Drawings/DrawingValidation.cs | 7 ++ src/Kompas.Mcp.Host/Tools/DrawingTools.cs | 18 ++++ .../DimensionOrientationsTests.cs | 12 +++ .../DrawingValidationTests.cs | 12 +++ .../Integration/DrawingDiametralTests.cs | 101 ++++++++++++++++++ 8 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 tests/Kompas.Mcp.Tests/Integration/DrawingDiametralTests.cs diff --git a/docs/superpowers/specs/2026-05-27-drawing-diametral-dimension-design.md b/docs/superpowers/specs/2026-05-27-drawing-diametral-dimension-design.md index cc273cb..221691c 100644 --- a/docs/superpowers/specs/2026-05-27-drawing-diametral-dimension-design.md +++ b/docs/superpowers/specs/2026-05-27-drawing-diametral-dimension-design.md @@ -1,7 +1,22 @@ # Дизайн: диаметральный размер на виде чертежа (drawing_add_diametral_dimension) через API7 **Дата:** 2026-05-27 -**Статус:** спайк зелёный (значение=диаметр подтверждено); спроектировано; на ревью (Codex) +**Статус:** реализовано и проверено (4 интеграционных + unit, всего 217); ревью Codex спека учтено + +## Правки по ревью Codex + +- **#5** `((IDimensionText)dim).AutoNominalValue = true` выставляем явно (консистентно с линейным). +- **#7** извлечь `RequireSymbols2DContainer(viewNumber)` (= `(ISymbols2DContainer)FindView(...)`) — + переиспользуют диаметральный путь и счётчики (радиальный — следующий, тот же паттерн). +- **#4** конверсия °→рад вынесена в чистый `DimensionAngles.ToRadians(deg)` + unit-тест (значение от + угла не зависит → математику проверяем отдельно). +- **#2** `DimensionType` НЕ задаём — принимаем COM-дефолт (стандартный стиль; полярность bool не + подтверждена, спайк с дефолтом дал `Valid` + верное значение). Зафиксировано комментарием. +- **#1** размер «свободный» (по Xc/Yc/Radius, без `BaseObject`) — как у линейного; LLM задаёт верные + параметры окружности. Ассоциативная привязка — будущее. Отражено в описании инструмента. +- **#3** `angle` — любой конечный (направление выноски, КОМПАС нормализует); валидируем лишь + конечность. **#6** откат после `Add()` для диаметрального не форсируется (radius>0 → всегда Valid, + value=2R>0); код отката идентичен протестированному линейному (zero-projection тест). ## Цель diff --git a/src/Kompas.Mcp.Core/Drawings/DimensionOrientation.cs b/src/Kompas.Mcp.Core/Drawings/DimensionOrientation.cs index 29e6e8f..0c463d4 100644 --- a/src/Kompas.Mcp.Core/Drawings/DimensionOrientation.cs +++ b/src/Kompas.Mcp.Core/Drawings/DimensionOrientation.cs @@ -13,6 +13,18 @@ public enum DimensionOrientation Parallel, } +/// Конверсии углов размеров (2D-API КОМПАС использует радианы). +public static class DimensionAngles +{ + /// Градусы → радианы (угол выноски в API задаётся в радианах). + public static double ToRadians(double degrees) + { + if (!double.IsFinite(degrees)) + throw new ArgumentException("Угол должен быть конечным числом.", nameof(degrees)); + return degrees * Math.PI / 180.0; + } +} + /// Маппинг ориентации размера ↔ перечисление КОМПАС и разбор строки. public static class DimensionOrientations { diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingService.cs b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs index ee0023f..458c2d5 100644 --- a/src/Kompas.Mcp.Core/Drawings/DrawingService.cs +++ b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs @@ -100,9 +100,7 @@ public sealed class DrawingService DrawingValidation.RequireFiniteCoords(x1, y1, x2, y2, x3, y3); DrawingValidation.RequireDistinctPoints(x1, y1, x2, y2); - var view = FindView(viewNumber); - var symbols = view as ISymbols2DContainer - ?? throw new InvalidOperationException("Вид не приводится к ISymbols2DContainer."); + var (view, symbols) = RequireSymbols2DContainer(viewNumber); var dim = symbols.LineDimensions?.Add() ?? throw new InvalidOperationException("LineDimensions.Add вернул null."); try @@ -136,11 +134,63 @@ public sealed class DrawingService /// Число линейных размеров на виде по номеру (для проверки/диагностики). Размеры живут /// в ISymbols2DContainer вида, а НЕ в IView.ObjectCount (тот считает геометрию). public Task GetViewLineDimensionCountAsync(int viewNumber, CancellationToken ct = default) - => _dispatcher.InvokeAsync(() => + => _dispatcher.InvokeAsync(() => RequireSymbols2DContainer(viewNumber).Symbols.LineDimensions?.Count ?? 0, ct); + + /// + /// Поставить диаметральный размер (Ø) окружности с центром (xc,yc) и радиусом + /// (ЛОКАЛЬНАЯ СК вида, мм) на виде. — направление выноски (градусы). + /// Значение (диаметр = 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); + + private DrawingDimensionResult AddDiametralDimensionCore(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.DiametralDimensions?.Add() + ?? throw new InvalidOperationException("DiametralDimensions.Add вернул null."); + try { - var symbols = (ISymbols2DContainer)FindView(viewNumber); - return symbols.LineDimensions?.Count ?? 0; - }, ct); + dim.Xc = xc; dim.Yc = yc; dim.Radius = radius; dim.Angle = angleRad; + // DimensionType (стиль линии) не задаём — принимаем COM-дефолт (стандартный стиль). + ((IDimensionText)dim).AutoNominalValue = true; + + if (!dim.Update()) + throw new InvalidOperationException("IDiametralDimension.Update вернул FALSE — размер не построен."); + if (!dim.Valid) + throw new InvalidOperationException("Диаметральный размер построен невалидным."); + + var value = ((IDimensionText)dim).NominalValue; // диаметр = 2·radius + if (Math.Abs(value) < 1e-6) + throw new InvalidOperationException("Диаметральный размер измерил нулевое значение."); + return new DrawingDimensionResult { Value = value, ViewNumber = view.Number }; + } + catch + { + try { dim.Delete(); } catch { /* лучшее усилие */ } + throw; + } + } + + /// Число диаметральных размеров на виде (для проверки попадания в вид — они в + /// ISymbols2DContainer, а не в IView.ObjectCount). + public Task GetViewDiametralDimensionCountAsync(int viewNumber, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => RequireSymbols2DContainer(viewNumber).Symbols.DiametralDimensions?.Count ?? 0, ct); + + /// Вид + его ISymbols2DContainer (QI от вида). Общий для линейных/диаметральных/… размеров. + private (IView View, ISymbols2DContainer Symbols) RequireSymbols2DContainer(int viewNumber) + { + var view = FindView(viewNumber); + var symbols = view as ISymbols2DContainer + ?? throw new InvalidOperationException("Вид не приводится к ISymbols2DContainer."); + return (view, symbols); + } /// Найти вид: при viewNumber ≤ 0 — первый несистемный (Number != 0); иначе по номеру. private IView FindView(int viewNumber) diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingValidation.cs b/src/Kompas.Mcp.Core/Drawings/DrawingValidation.cs index 032a2bc..2ce1ef0 100644 --- a/src/Kompas.Mcp.Core/Drawings/DrawingValidation.cs +++ b/src/Kompas.Mcp.Core/Drawings/DrawingValidation.cs @@ -41,6 +41,13 @@ public static class DrawingValidation throw new ArgumentException("Координаты должны быть конечными числами (не NaN/Infinity)."); } + /// Проверить радиус: конечный и строго положительный. + public static void RequirePositiveRadius(double radius) + { + if (!double.IsFinite(radius) || radius <= 0) + throw new ArgumentOutOfRangeException(nameof(radius), "Радиус должен быть конечным и > 0."); + } + /// Минимальное расстояние между выносными точками линейного размера (мм). private const double MinPointDistance = 1e-7; diff --git a/src/Kompas.Mcp.Host/Tools/DrawingTools.cs b/src/Kompas.Mcp.Host/Tools/DrawingTools.cs index 9341593..7ea265b 100644 --- a/src/Kompas.Mcp.Host/Tools/DrawingTools.cs +++ b/src/Kompas.Mcp.Host/Tools/DrawingTools.cs @@ -65,4 +65,22 @@ public sealed class DrawingTools(KompasSession session, DrawingService drawing) var r = await drawing.AddLinearDimensionAsync(viewNumber, x1, y1, x2, y2, x3, y3, o); return $"Линейный размер ({orientation}) поставлен на виде {r.ViewNumber}, значение {r.Value:0.###} мм."; } + + [McpServerTool(Name = "drawing_add_diametral_dimension")] + [Description("Поставить диаметральный размер (Ø) окружности на виде активного чертежа. Окружность " + + "задаётся центром (xc,yc) и радиусом radius в ЛОКАЛЬНОЙ СК вида (мм; при масштабе 1:1 = координаты " + + "модели на плоскости вида) — задайте параметры реальной окружности вида. angle — направление выноски " + + "в градусах. viewNumber — номер вида из drawing_create_standard_views (0 = первый/главный). " + + "Значение (диаметр = 2·radius) измеряется автоматически. Возвращает диаметр и номер вида.")] + public async Task AddDiametralDimension( + [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.AddDiametralDimensionAsync(viewNumber, xc, yc, radius, angle); + return $"Диаметральный размер поставлен на виде {r.ViewNumber}, Ø{r.Value:0.###} мм."; + } } diff --git a/tests/Kompas.Mcp.Tests/DimensionOrientationsTests.cs b/tests/Kompas.Mcp.Tests/DimensionOrientationsTests.cs index 5cdeaeb..0023dac 100644 --- a/tests/Kompas.Mcp.Tests/DimensionOrientationsTests.cs +++ b/tests/Kompas.Mcp.Tests/DimensionOrientationsTests.cs @@ -21,4 +21,16 @@ public sealed class DimensionOrientationsTests // ToKompas (→ ksLineDimensionOrientationEnum) намеренно НЕ покрыт unit-тестом: тестовый проект // не ссылается на Kompas6Constants (граница проекта). Маппинг тривиален и проверяется интеграцией // (horizontal → |X2-X1|, vertical → |Y2-Y1|). + + [Theory] + [InlineData(0, 0)] + [InlineData(90, Math.PI / 2)] + [InlineData(180, Math.PI)] + [InlineData(-45, -Math.PI / 4)] + public void ToRadians_converts_degrees(double degrees, double expected) + => Assert.Equal(expected, DimensionAngles.ToRadians(degrees), 9); + + [Fact] + public void ToRadians_throws_on_non_finite() + => Assert.Throws(() => DimensionAngles.ToRadians(double.NaN)); } diff --git a/tests/Kompas.Mcp.Tests/DrawingValidationTests.cs b/tests/Kompas.Mcp.Tests/DrawingValidationTests.cs index ab20534..8d6584d 100644 --- a/tests/Kompas.Mcp.Tests/DrawingValidationTests.cs +++ b/tests/Kompas.Mcp.Tests/DrawingValidationTests.cs @@ -68,4 +68,16 @@ public sealed class DrawingValidationTests Assert.Throws(() => DrawingValidation.RequireDistinctPoints(10, 20, 10, 20)); Assert.Null(Record.Exception(() => DrawingValidation.RequireDistinctPoints(0, 0, 40, 0))); } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void RequirePositiveRadius_throws_on_non_positive_or_non_finite(double radius) + => Assert.Throws(() => DrawingValidation.RequirePositiveRadius(radius)); + + [Fact] + public void RequirePositiveRadius_accepts_positive() + => Assert.Null(Record.Exception(() => DrawingValidation.RequirePositiveRadius(15))); } diff --git a/tests/Kompas.Mcp.Tests/Integration/DrawingDiametralTests.cs b/tests/Kompas.Mcp.Tests/Integration/DrawingDiametralTests.cs new file mode 100644 index 0000000..0dcfac5 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/DrawingDiametralTests.cs @@ -0,0 +1,101 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Drawings; +using Kompas.Mcp.Core.Modeling; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: диаметральный размер на виде (API7 ISymbols2DContainer.DiametralDimensions). +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class DrawingDiametralTests : IntegrationTestBase +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly DrawingService _drawing; + + public DrawingDiametralTests(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 Diametral_dimension_value_is_diameter() + { + var partFile = await BuildAndSaveBoxAsync(); + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + await _drawing.CreateStandardViewsAsync(partFile, 1.0, 100, 150); + // Радиус 15 → диаметр 30. + var r = await _drawing.AddDiametralDimensionAsync(viewNumber: 0, xc: 10, yc: 10, radius: 15, angleDeg: 45); + Assert.InRange(r.Value, 29.9, 30.1); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Targets_specific_view_and_increments_diametral_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.GetViewDiametralDimensionCountAsync(targetNumber); + var r = await _drawing.AddDiametralDimensionAsync(targetNumber, xc: 5, yc: 5, radius: 8, angleDeg: 30); + var after = await _drawing.GetViewDiametralDimensionCountAsync(targetNumber); + + Assert.Equal(targetNumber, r.ViewNumber); + Assert.Equal(before + 1, after); + Assert.InRange(r.Value, 15.9, 16.1); // Ø = 2·8 + } + 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.AddDiametralDimensionAsync(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.AddDiametralDimensionAsync(viewNumber: 0, xc: 0, yc: 0, radius: 10, angleDeg: 0)); + } + finally { await _docs.CloseAsync(save: false); } + } +}