feat(core): SnapshotService — рендер 3D-модели в PNG (S4, "зрение агента")

- RasterImageFormat (PNG/JPG/BMP/...) + MIME-типы + парсинг
- SnapshotService: ksDocument3D.SaveAsToRasterFormat -> временный файл -> байты
  (resultArrayBytes при заданном имени файла не заполняется; рендер в файл надёжен)
- tests: снимок активной детали возвращает валидный PNG (визуально проверено)
- docs/OPEN_QUESTIONS.md: зафиксирован нюанс resultArrayBytes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 23:48:35 +03:00
parent f0995a67e6
commit d4502409de
4 changed files with 181 additions and 0 deletions
+7
View File
@@ -31,3 +31,10 @@
## Журнал решений по ходу
(дополняется автоматически во время работы)
### ✅ Снимок 3D: рендер через файл, а не resultArrayBytes
`ksDocument3D.SaveAsToRasterFormat(fileName, param)` при **непустом** `fileName` возвращает TRUE,
пишет корректный PNG на диск, но `param.resultArrayBytes` остаётся **пустым**. Поэтому
`SnapshotService` рендерит во временный файл и читает байты обратно (надёжно проверено на v24 Home).
**На проработку:** пустое имя файла + `returnResultAsArrayBytes=true` для чистого in-memory —
не проверено; текущий путь через temp-файл работает.
@@ -0,0 +1,38 @@
namespace Kompas.Mcp.Core.Vision;
/// <summary>Формат растра для снимка модели (значения ksRasterFormatEnum / ksRasterFormatParam.format).</summary>
public enum RasterImageFormat : short
{
Bmp = 0,
Gif = 1,
Jpg = 2,
Png = 3,
Tif = 4,
Tga = 5,
}
public static class RasterImageFormats
{
/// <summary>MIME-тип для image-контента MCP.</summary>
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)),
};
}
@@ -0,0 +1,91 @@
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Threading;
using Kompas6API5;
namespace Kompas.Mcp.Core.Vision;
/// <summary>Результат снимка модели.</summary>
public sealed record SnapshotResult
{
public required byte[] Bytes { get; init; }
public required RasterImageFormat Format { get; init; }
public string MimeType => Format.MimeType();
}
/// <summary>
/// «Зрение агента»: рендер активного 3D-документа в растр (PNG и пр.) прямо в память
/// через API5 <c>ksDocument3D.SaveAsToRasterFormat</c> + <c>resultArrayBytes</c>.
/// </summary>
[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));
}
/// <summary>
/// Снять текущий вид активного 3D-документа в растр.
/// </summary>
/// <param name="format">Формат изображения (по умолчанию PNG).</param>
/// <param name="resolution">Разрешение, DPI; 0 — текущее экранное.</param>
/// <param name="greyScale">Сохранять в оттенках серого.</param>
/// <param name="saveToFile">Если задан — дополнительно записать файл по пути.</param>
public Task<SnapshotResult> 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 { /* временный файл — не критично */ }
}
}
}
}
@@ -0,0 +1,45 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Vision;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>Интеграция: снимок модели в растр («зрение агента»).</summary>
[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);
}
}
}