diff --git a/docs/superpowers/specs/2026-05-27-drawing-standard-views-design.md b/docs/superpowers/specs/2026-05-27-drawing-standard-views-design.md index 8f29851..78a275e 100644 --- a/docs/superpowers/specs/2026-05-27-drawing-standard-views-design.md +++ b/docs/superpowers/specs/2026-05-27-drawing-standard-views-design.md @@ -1,7 +1,22 @@ # Дизайн: стандартные виды чертежа (drawing_create_standard_views) через API7 **Дата:** 2026-05-27 -**Статус:** спайк зелёный (механизм подтверждён вживую); спроектировано; на ревью (Codex) +**Статус:** спайк зелёный; ревью Codex учтено (10 замечаний); реализуется + +## Решения по ревью Codex + +- **#1** файл модели: `File.Exists` И `FileInfo.Length > 0` (несохранённый/битый файл). +- **#3/#5/#6** непустота видов — гарантия В СЕРВИСЕ: после создания суммируем `IView.ObjectCount` + по коллекции; если 0 → ошибка «виды пусты (модель не спроецировалась)». Это и проверяет, что + rebuild не нужен (геометрия уже есть). Координатные проверки (`GetProjectionPoint`) — избыточны. +- **#8** строгий контракт: `created == 3` (иначе ошибка с `before/after/ok`), а не `> 0`. +- **#9** позиция главного вида — опциональные `x`,`y` (дефолт 100/150), чтобы повторный вызов не + клал виды стопкой; `dx`/`dy`=20 фиксированы (YAGNI). +- **#10** возврат — record `DrawingViewsResult { Created, Total }` (не голый int) для отчёта и + будущей адресации видов. +- **#2** негативный unit-тест на чужое расширение (`.step`) — в плане. +- **#4** точечный release RCW отклонён (конвенция v2-2, как `AssemblyService`). **#7** неверный + `ProjectionName` не тестируем — `"#Спереди"` захардкожен, не управляется пользователем. ## Цель diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingService.cs b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs new file mode 100644 index 0000000..3b290c0 --- /dev/null +++ b/src/Kompas.Mcp.Core/Drawings/DrawingService.cs @@ -0,0 +1,91 @@ +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Threading; +using Kompas6Constants; +using KompasAPI7; + +namespace Kompas.Mcp.Core.Drawings; + +/// +/// Операции с 2D-чертежами через API7 (IViews). Все вызовы — на STA-потоке +/// . +/// +[SupportedOSPlatform("windows")] +public sealed class DrawingService +{ + private readonly KompasSession _session; + private readonly KompasDispatcher _dispatcher; + + public DrawingService(KompasSession session, KompasDispatcher dispatcher) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + /// + /// Создать на активном чертеже стандартные ассоциативные виды (спереди/сверху/слева) сохранённой + /// 3D-модели (.m3d/.a3d). Главный вид привязывается к точке + /// (,) листа, мм; масштаб — . + /// + public Task CreateStandardViewsAsync(string partFilePath, double scale, + double x, double y, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => CreateStandardViewsCore(partFilePath, scale, x, y), ct); + + // Стандартная тройка проекций: ksPtFront=1, ksPtUp=3, ksPtLeft=5 (ksRelativeProjectionTypeEnum). + private static readonly object[] StandardProjections = { 1, 3, 5 }; + private const int ExpectedCreated = 3; + private const double GapMm = 20; // зазоры между видами (DX=DY) + + private DrawingViewsResult CreateStandardViewsCore(string partFilePath, double scale, double x, double y) + { + var path = DrawingValidation.NormalizeModelPath(partFilePath); + DrawingValidation.RequireScale(scale); + if (!double.IsFinite(x) || !double.IsFinite(y)) + throw new ArgumentException("Координаты привязки вида должны быть конечными числами."); + if (!File.Exists(path)) + throw new FileNotFoundException($"Файл модели не найден: {path}", path); + if (new FileInfo(path).Length == 0) + throw new InvalidOperationException($"Файл модели пуст (не сохранён?): {path}"); + + var views = RequireActiveDrawingViews(); + var before = views.Count; + + if (!views.AddStandartViews(path, "#Спереди", StandardProjections, x, y, scale, GapMm, GapMm)) + throw new InvalidOperationException( + $"AddStandartViews вернул FALSE — виды не созданы (файл модели или проекция недоступны): {path}"); + + var created = views.Count - before; + if (created != ExpectedCreated) + throw new InvalidOperationException( + $"Ожидалось {ExpectedCreated} вида, создано {created} (было {before}, стало {views.Count})."); + + // Гарантия непустоты: у спроецированных видов должна быть геометрия (иначе модель не + // спроецировалась — пустые виды). Системный вид #0 имеет ObjectCount 0, поэтому суммируем. + if (TotalObjectCount(views) <= 0) + throw new InvalidOperationException( + "Стандартные виды созданы, но пусты (модель не спроецировалась). Проверьте файл модели."); + + return new DrawingViewsResult { Created = created, Total = views.Count }; + } + + private IViews RequireActiveDrawingViews() + { + var doc = _session.Application.ActiveDocument + ?? throw new InvalidOperationException("Нет активного документа. Создайте чертёж: document_create drawing."); + if (doc.DocumentType != DocumentTypeEnum.ksDocumentDrawing) + throw new InvalidOperationException( + $"Активный документ не является чертежом (тип: {doc.DocumentType}). Создайте чертёж: document_create drawing."); + if (doc is not IKompasDocument2D doc2d) + throw new InvalidOperationException("Активный документ не приводится к IKompasDocument2D."); + return doc2d.ViewsAndLayersManager?.Views + ?? throw new InvalidOperationException("Не удалось получить коллекцию видов чертежа."); + } + + private static int TotalObjectCount(IViews views) + { + int sum = 0; + foreach (var o in views) + if (o is IView v) + sum += v.ObjectCount; + return sum; + } +} diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingValidation.cs b/src/Kompas.Mcp.Core/Drawings/DrawingValidation.cs new file mode 100644 index 0000000..bb9d0c3 --- /dev/null +++ b/src/Kompas.Mcp.Core/Drawings/DrawingValidation.cs @@ -0,0 +1,35 @@ +namespace Kompas.Mcp.Core.Drawings; + +/// Чистые валидаторы операций чертежа (без COM) — выделены для unit-тестов. +public static class DrawingValidation +{ + /// Допустимые расширения файла-источника модели: деталь (.m3d) и сборка (.a3d). + private static readonly HashSet AllowedExtensions = + new(StringComparer.OrdinalIgnoreCase) { ".m3d", ".a3d" }; + + /// + /// Нормализовать путь к файлу 3D-модели: обрезать пробелы, проверить непустоту и расширение + /// (.m3d/.a3d), вернуть абсолютный путь. Существование файла НЕ проверяет (это делает сервис). + /// + public static string NormalizeModelPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Путь к файлу модели не задан.", nameof(path)); + + var trimmed = path.Trim(); + var ext = Path.GetExtension(trimmed); + if (!AllowedExtensions.Contains(ext)) + throw new ArgumentException( + $"Недопустимое расширение файла модели '{ext}'. Ожидается .m3d (деталь) или .a3d (сборка).", + nameof(path)); + + return Path.GetFullPath(trimmed); + } + + /// Проверить масштаб чертежа: конечный и строго положительный. + public static void RequireScale(double scale) + { + if (!double.IsFinite(scale) || scale <= 0) + throw new ArgumentOutOfRangeException(nameof(scale), "Масштаб должен быть конечным и > 0."); + } +} diff --git a/src/Kompas.Mcp.Core/Drawings/DrawingViewsResult.cs b/src/Kompas.Mcp.Core/Drawings/DrawingViewsResult.cs new file mode 100644 index 0000000..336cec7 --- /dev/null +++ b/src/Kompas.Mcp.Core/Drawings/DrawingViewsResult.cs @@ -0,0 +1,11 @@ +namespace Kompas.Mcp.Core.Drawings; + +/// Результат создания стандартных видов на чертеже. +public sealed record DrawingViewsResult +{ + /// Сколько видов создано этим вызовом. + public required int Created { get; init; } + + /// Общее число видов на чертеже после операции (включая системный вид). + public required int Total { get; init; } +} diff --git a/src/Kompas.Mcp.Host/Program.cs b/src/Kompas.Mcp.Host/Program.cs index 7b17e3a..6a047c1 100644 --- a/src/Kompas.Mcp.Host/Program.cs +++ b/src/Kompas.Mcp.Host/Program.cs @@ -2,6 +2,7 @@ using Kompas.Mcp.Core; using Kompas.Mcp.Core.Assemblies; using Kompas.Mcp.Core.Conversion; using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Drawings; using Kompas.Mcp.Core.Editing; using Kompas.Mcp.Core.Modeling; using Kompas.Mcp.Core.Query; @@ -25,6 +26,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Kompas.Mcp.Host/Tools/DrawingTools.cs b/src/Kompas.Mcp.Host/Tools/DrawingTools.cs new file mode 100644 index 0000000..2be1ec7 --- /dev/null +++ b/src/Kompas.Mcp.Host/Tools/DrawingTools.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; +using Kompas.Mcp.Core; +using Kompas.Mcp.Core.Drawings; +using ModelContextProtocol.Server; + +namespace Kompas.Mcp.Host.Tools; + +/// Инструменты 2D-чертежа (виды, оформление). +[McpServerToolType] +public sealed class DrawingTools(KompasSession session, DrawingService drawing) +{ + [McpServerTool(Name = "drawing_create_standard_views")] + [Description("На активном чертеже создать стандартные ассоциативные виды (спереди, сверху, слева) " + + "сохранённой 3D-модели (.m3d деталь / .a3d сборка). Активный документ должен быть чертежом " + + "(document_create drawing); модель должна быть сохранена в файл (document_save_as). " + + "scale — масштаб (1.0 = 1:1); x,y — точка привязки главного вида на листе (мм). " + + "Возвращает число созданных видов.")] + public async Task CreateStandardViews( + [Description("Путь к файлу 3D-модели (.m3d/.a3d)")] string partFilePath, + [Description("Масштаб видов (1.0 = 1:1)")] double scale = 1.0, + [Description("X привязки главного вида на листе, мм")] double x = 100, + [Description("Y привязки главного вида на листе, мм")] double y = 150) + { + await session.ConnectAsync(); + var r = await drawing.CreateStandardViewsAsync(partFilePath, scale, x, y); + return $"Создано {r.Created} стандартных вида на чертеже (всего видов: {r.Total}, масштаб {scale})."; + } +} diff --git a/tests/Kompas.Mcp.Tests/DrawingValidationTests.cs b/tests/Kompas.Mcp.Tests/DrawingValidationTests.cs new file mode 100644 index 0000000..632bf03 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/DrawingValidationTests.cs @@ -0,0 +1,47 @@ +using Kompas.Mcp.Core.Drawings; + +namespace Kompas.Mcp.Tests; + +[Trait("Category", "Unit")] +public sealed class DrawingValidationTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void NormalizeModelPath_throws_on_empty(string? path) + => Assert.Throws(() => DrawingValidation.NormalizeModelPath(path)); + + [Theory] + [InlineData(@"C:\m\part.step")] + [InlineData(@"C:\m\part.txt")] + [InlineData(@"C:\m\part")] + public void NormalizeModelPath_throws_on_wrong_extension(string path) + => Assert.Throws(() => DrawingValidation.NormalizeModelPath(path)); + + [Theory] + [InlineData(@"C:\m\part.m3d", ".m3d")] + [InlineData(@"C:\m\frame.a3d", ".a3d")] + [InlineData(@" C:\m\part.M3D ", ".m3d")] + public void NormalizeModelPath_accepts_model_files(string path, string expectedExt) + { + var result = DrawingValidation.NormalizeModelPath(path); + Assert.True(Path.IsPathFullyQualified(result)); + Assert.EndsWith(expectedExt, result, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void RequireScale_throws_on_non_positive_or_non_finite(double scale) + => Assert.Throws(() => DrawingValidation.RequireScale(scale)); + + [Theory] + [InlineData(1.0)] + [InlineData(0.5)] + [InlineData(2.0)] + public void RequireScale_accepts_positive(double scale) + => Assert.Null(Record.Exception(() => DrawingValidation.RequireScale(scale))); +} diff --git a/tests/Kompas.Mcp.Tests/Integration/DrawingTests.cs b/tests/Kompas.Mcp.Tests/Integration/DrawingTests.cs new file mode 100644 index 0000000..9908702 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/DrawingTests.cs @@ -0,0 +1,88 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Drawings; +using Kompas.Mcp.Core.Modeling; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: стандартные ассоциативные виды чертежа (API7 IViews.AddStandartViews). +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class DrawingTests : IntegrationTestBase +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly DrawingService _drawing; + + public DrawingTests(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); } + + Assert.True(File.Exists(path), $"Файл детали не сохранён: {path}"); + Assert.True(new FileInfo(path).Length > 0, "Файл детали пуст."); + return path; + } + + [Fact] + public async Task Creates_three_standard_views_on_drawing() + { + var partFile = await BuildAndSaveBoxAsync(); + + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + var r = await _drawing.CreateStandardViewsAsync(partFile, scale: 1.0, x: 100, y: 150); + + // Создано ровно 3 вида (спереди/сверху/слева); всего на чертеже +3 к системному виду. + Assert.Equal(3, r.Created); + Assert.True(r.Total >= 4, $"Ожидалось ≥4 видов всего (системный + 3), получено {r.Total}."); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Rejects_non_drawing_document() + { + // Активный документ — деталь (не чертёж): понятная ошибка. + var partFile = await BuildAndSaveBoxAsync(); + + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + await Assert.ThrowsAsync( + () => _drawing.CreateStandardViewsAsync(partFile, scale: 1.0, x: 100, y: 150)); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Missing_model_file_throws() + { + var missing = Path.Combine(TestPaths.Scratch, $"no_such_{Guid.NewGuid():N}.m3d"); + + await _docs.CreateAsync(KompasDocumentType.Drawing); + try + { + await Assert.ThrowsAsync( + () => _drawing.CreateStandardViewsAsync(missing, scale: 1.0, x: 100, y: 150)); + } + finally { await _docs.CloseAsync(save: false); } + } +}