From d4502409decfa364d2b1c5cfdca9484e8385cccd Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Mon, 25 May 2026 23:48:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(core):=20SnapshotService=20=E2=80=94=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=203D-=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=20=D0=B2=20PNG=20(S4,=20"=D0=B7=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0")?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RasterImageFormat (PNG/JPG/BMP/...) + MIME-типы + парсинг - SnapshotService: ksDocument3D.SaveAsToRasterFormat -> временный файл -> байты (resultArrayBytes при заданном имени файла не заполняется; рендер в файл надёжен) - tests: снимок активной детали возвращает валидный PNG (визуально проверено) - docs/OPEN_QUESTIONS.md: зафиксирован нюанс resultArrayBytes Co-Authored-By: Claude Opus 4.7 --- docs/OPEN_QUESTIONS.md | 7 ++ .../Vision/RasterImageFormat.cs | 38 ++++++++ src/Kompas.Mcp.Core/Vision/SnapshotService.cs | 91 +++++++++++++++++++ .../Integration/SnapshotTests.cs | 45 +++++++++ 4 files changed, 181 insertions(+) create mode 100644 src/Kompas.Mcp.Core/Vision/RasterImageFormat.cs create mode 100644 src/Kompas.Mcp.Core/Vision/SnapshotService.cs create mode 100644 tests/Kompas.Mcp.Tests/Integration/SnapshotTests.cs diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md index a004845..9d13625 100644 --- a/docs/OPEN_QUESTIONS.md +++ b/docs/OPEN_QUESTIONS.md @@ -31,3 +31,10 @@ ## Журнал решений по ходу (дополняется автоматически во время работы) + +### ✅ Снимок 3D: рендер через файл, а не resultArrayBytes +`ksDocument3D.SaveAsToRasterFormat(fileName, param)` при **непустом** `fileName` возвращает TRUE, +пишет корректный PNG на диск, но `param.resultArrayBytes` остаётся **пустым**. Поэтому +`SnapshotService` рендерит во временный файл и читает байты обратно (надёжно проверено на v24 Home). +**На проработку:** пустое имя файла + `returnResultAsArrayBytes=true` для чистого in-memory — +не проверено; текущий путь через temp-файл работает. diff --git a/src/Kompas.Mcp.Core/Vision/RasterImageFormat.cs b/src/Kompas.Mcp.Core/Vision/RasterImageFormat.cs new file mode 100644 index 0000000..27357fc --- /dev/null +++ b/src/Kompas.Mcp.Core/Vision/RasterImageFormat.cs @@ -0,0 +1,38 @@ +namespace Kompas.Mcp.Core.Vision; + +/// Формат растра для снимка модели (значения ksRasterFormatEnum / ksRasterFormatParam.format). +public enum RasterImageFormat : short +{ + Bmp = 0, + Gif = 1, + Jpg = 2, + Png = 3, + Tif = 4, + Tga = 5, +} + +public static class RasterImageFormats +{ + /// MIME-тип для image-контента MCP. + public static string MimeType(this RasterImageFormat format) => format switch + { + RasterImageFormat.Png => "image/png", + RasterImageFormat.Jpg => "image/jpeg", + RasterImageFormat.Bmp => "image/bmp", + RasterImageFormat.Gif => "image/gif", + RasterImageFormat.Tif => "image/tiff", + RasterImageFormat.Tga => "image/x-tga", + _ => "application/octet-stream", + }; + + public static RasterImageFormat Parse(string value) => value.Trim().ToLowerInvariant() switch + { + "png" => RasterImageFormat.Png, + "jpg" or "jpeg" => RasterImageFormat.Jpg, + "bmp" => RasterImageFormat.Bmp, + "gif" => RasterImageFormat.Gif, + "tif" or "tiff" => RasterImageFormat.Tif, + "tga" => RasterImageFormat.Tga, + _ => throw new ArgumentException($"Неизвестный формат растра '{value}'.", nameof(value)), + }; +} diff --git a/src/Kompas.Mcp.Core/Vision/SnapshotService.cs b/src/Kompas.Mcp.Core/Vision/SnapshotService.cs new file mode 100644 index 0000000..d839ca7 --- /dev/null +++ b/src/Kompas.Mcp.Core/Vision/SnapshotService.cs @@ -0,0 +1,91 @@ +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Threading; +using Kompas6API5; + +namespace Kompas.Mcp.Core.Vision; + +/// Результат снимка модели. +public sealed record SnapshotResult +{ + public required byte[] Bytes { get; init; } + public required RasterImageFormat Format { get; init; } + public string MimeType => Format.MimeType(); +} + +/// +/// «Зрение агента»: рендер активного 3D-документа в растр (PNG и пр.) прямо в память +/// через API5 ksDocument3D.SaveAsToRasterFormat + resultArrayBytes. +/// +[SupportedOSPlatform("windows")] +public sealed class SnapshotService +{ + private readonly KompasSession _session; + private readonly KompasDispatcher _dispatcher; + + public SnapshotService(KompasSession session, KompasDispatcher dispatcher) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + /// + /// Снять текущий вид активного 3D-документа в растр. + /// + /// Формат изображения (по умолчанию PNG). + /// Разрешение, DPI; 0 — текущее экранное. + /// Сохранять в оттенках серого. + /// Если задан — дополнительно записать файл по пути. + public Task CaptureActiveModelAsync( + RasterImageFormat format = RasterImageFormat.Png, + int resolution = 0, + bool greyScale = false, + string? saveToFile = null, + CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => CaptureCore(format, resolution, greyScale, saveToFile), ct); + + private SnapshotResult CaptureCore(RasterImageFormat format, int resolution, bool greyScale, string? saveToFile) + { + var doc3d = _session.Kompas.ActiveDocument3D() as ksDocument3D + ?? throw new InvalidOperationException("Нет активного 3D-документа для снимка."); + + var param = doc3d.RasterFormatParam() as ksRasterFormatParam + ?? throw new InvalidOperationException("Не удалось получить ksRasterFormatParam."); + + param.Init(); + param.format = (short)format; + param.colorBPP = 24; + param.extResolution = resolution; + param.greyScale = greyScale; + param.onlyThinLine = false; + + // Рендерим в файл и читаем байты обратно: для 3D-документа resultArrayBytes + // при заданном имени файла не заполняется (см. docs/OPEN_QUESTIONS.md), а файловый + // вывод работает надёжно. + param.returnResultAsArrayBytes = false; + + var outFile = saveToFile ?? Path.Combine( + Path.GetTempPath(), $"kompas-snap-{Guid.NewGuid():N}.{format.ToString().ToLowerInvariant()}"); + var deleteAfter = saveToFile is null; + + try + { + if (!doc3d.SaveAsToRasterFormat(outFile, param)) + throw new InvalidOperationException("SaveAsToRasterFormat вернул FALSE (рендер не удался)."); + if (!File.Exists(outFile)) + throw new InvalidOperationException("Файл снимка не создан."); + + var bytes = File.ReadAllBytes(outFile); + if (bytes.Length == 0) + throw new InvalidOperationException("Снимок пуст."); + + return new SnapshotResult { Bytes = bytes, Format = format }; + } + finally + { + if (deleteAfter && File.Exists(outFile)) + { + try { File.Delete(outFile); } catch { /* временный файл — не критично */ } + } + } + } +} diff --git a/tests/Kompas.Mcp.Tests/Integration/SnapshotTests.cs b/tests/Kompas.Mcp.Tests/Integration/SnapshotTests.cs new file mode 100644 index 0000000..1b8d00a --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/SnapshotTests.cs @@ -0,0 +1,45 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Vision; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: снимок модели в растр («зрение агента»). +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class SnapshotTests +{ + private readonly DocumentService _docs; + private readonly SnapshotService _snap; + + public SnapshotTests(KompasFixture fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _snap = new SnapshotService(fx.Session, fx.Dispatcher); + } + + [Fact] + public async Task Snapshot_active_part_returns_valid_png() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + var path = TestPaths.NewFile(".png"); + var snap = await _snap.CaptureActiveModelAsync(RasterImageFormat.Png, saveToFile: path); + + Assert.Equal(RasterImageFormat.Png, snap.Format); + Assert.True(snap.Bytes.Length > 100, "Снимок подозрительно мал."); + + // Сигнатура PNG: 89 50 4E 47 0D 0A 1A 0A + Assert.Equal(0x89, snap.Bytes[0]); + Assert.Equal((byte)'P', snap.Bytes[1]); + Assert.Equal((byte)'N', snap.Bytes[2]); + Assert.Equal((byte)'G', snap.Bytes[3]); + + Assert.True(File.Exists(path)); + } + finally + { + await _docs.CloseAsync(save: false); + } + } +}