feat: drawing_create_standard_views — стандартные виды чертежа (API7)
Веха 2 (2D-ЧЕРТЁЖ), инкремент 1. Новый DrawingService (API7, namespace
Kompas.Mcp.Core.Drawings): doc2d.ViewsAndLayersManager.Views.AddStandartViews(
path, "#Спереди", {1,3,5}=front/top/left, x, y, scale, 20, 20). Новый DrawingTools
(инструмент drawing_create_standard_views), регистрация в DI.
Подтверждено спайком: object[]→SAFEARRAY VT_I4 ОК, 3 вида создаются (Count 1→4).
Ревью Codex спека учтено: File.Exists+Length>0; created==3 строго; гарантия
непустоты (сумма ObjectCount>0); опц. x,y (повторный вызов не стопкой); record
DrawingViewsResult{Created,Total}. Точечный release RCW отклонён (v2-2).
Валидация — чистый DrawingValidation (расширение .m3d/.a3d, scale>0).
Тесты: +16 unit (DrawingValidation), +3 integration (DrawingTests: создание 3 видов,
отклонение не-чертежа, несуществующий файл). Итого 114 unit + 61 integration = 175.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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` не тестируем — `"#Спереди"` захардкожен, не управляется пользователем.
|
||||
|
||||
## Цель
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Kompas.Mcp.Core.Threading;
|
||||
using Kompas6Constants;
|
||||
using KompasAPI7;
|
||||
|
||||
namespace Kompas.Mcp.Core.Drawings;
|
||||
|
||||
/// <summary>
|
||||
/// Операции с 2D-чертежами через API7 (<c>IViews</c>). Все вызовы — на STA-потоке
|
||||
/// <see cref="KompasDispatcher"/>.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создать на активном чертеже стандартные ассоциативные виды (спереди/сверху/слева) сохранённой
|
||||
/// 3D-модели <paramref name="partFilePath"/> (.m3d/.a3d). Главный вид привязывается к точке
|
||||
/// (<paramref name="x"/>,<paramref name="y"/>) листа, мм; масштаб — <paramref name="scale"/>.
|
||||
/// </summary>
|
||||
public Task<DrawingViewsResult> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Kompas.Mcp.Core.Drawings;
|
||||
|
||||
/// <summary>Чистые валидаторы операций чертежа (без COM) — выделены для unit-тестов.</summary>
|
||||
public static class DrawingValidation
|
||||
{
|
||||
/// <summary>Допустимые расширения файла-источника модели: деталь (.m3d) и сборка (.a3d).</summary>
|
||||
private static readonly HashSet<string> AllowedExtensions =
|
||||
new(StringComparer.OrdinalIgnoreCase) { ".m3d", ".a3d" };
|
||||
|
||||
/// <summary>
|
||||
/// Нормализовать путь к файлу 3D-модели: обрезать пробелы, проверить непустоту и расширение
|
||||
/// (.m3d/.a3d), вернуть абсолютный путь. Существование файла НЕ проверяет (это делает сервис).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Проверить масштаб чертежа: конечный и строго положительный.</summary>
|
||||
public static void RequireScale(double scale)
|
||||
{
|
||||
if (!double.IsFinite(scale) || scale <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(scale), "Масштаб должен быть конечным и > 0.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Kompas.Mcp.Core.Drawings;
|
||||
|
||||
/// <summary>Результат создания стандартных видов на чертеже.</summary>
|
||||
public sealed record DrawingViewsResult
|
||||
{
|
||||
/// <summary>Сколько видов создано этим вызовом.</summary>
|
||||
public required int Created { get; init; }
|
||||
|
||||
/// <summary>Общее число видов на чертеже после операции (включая системный вид).</summary>
|
||||
public required int Total { get; init; }
|
||||
}
|
||||
@@ -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<DocumentService>();
|
||||
builder.Services.AddSingleton<PartModeler>();
|
||||
builder.Services.AddSingleton<HoleService>();
|
||||
builder.Services.AddSingleton<AssemblyService>();
|
||||
builder.Services.AddSingleton<DrawingService>();
|
||||
builder.Services.AddSingleton<VariableService>();
|
||||
builder.Services.AddSingleton<SnapshotService>();
|
||||
builder.Services.AddSingleton<QueryService>();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel;
|
||||
using Kompas.Mcp.Core;
|
||||
using Kompas.Mcp.Core.Drawings;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Kompas.Mcp.Host.Tools;
|
||||
|
||||
/// <summary>Инструменты 2D-чертежа (виды, оформление).</summary>
|
||||
[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<string> 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}).";
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentOutOfRangeException>(() => 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)));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
using Kompas.Mcp.Core.Drawings;
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
|
||||
namespace Kompas.Mcp.Tests.Integration;
|
||||
|
||||
/// <summary>Интеграция: стандартные ассоциативные виды чертежа (API7 IViews.AddStandartViews).</summary>
|
||||
[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<string> 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<InvalidOperationException>(
|
||||
() => _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<FileNotFoundException>(
|
||||
() => _drawing.CreateStandardViewsAsync(missing, scale: 1.0, x: 100, y: 150));
|
||||
}
|
||||
finally { await _docs.CloseAsync(save: false); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user