Ответ надписи — габарит глифов; запись на диск создаёт каталог
ksConvertTextToCurve ВОЗВРАЩАЕТ ссылку на кривые, и только по ней ksGetObjGabaritRect даёт габарит букв: по текстовой ссылке это ячейка строки (Arial h=10 «HH» — 23.75 против 19.97 у глифов, второе совпадает с выдавленным телом). Агент вписывает надпись в поле по габариту, поэтому в ответе теперь оба числа. OutputPath.Prepare создаёт каталог назначения и делает путь абсолютным (document_save, export_step, снимок): КОМПАС в несуществующую папку молча не пишет, а диагностика при этом винила занятое имя файла — чинили не ту причину. Причины разведены.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Runtime.Versioning;
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
using Kompas.Mcp.Core.Interop;
|
||||
using Kompas.Mcp.Core.Threading;
|
||||
@@ -88,8 +88,8 @@ public sealed class ConversionService
|
||||
|
||||
private void ExportCore(string path, StepFormat format)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("Путь экспорта не задан.", nameof(path));
|
||||
// Каталог создаём сами: конвертер в несуществующую папку возвращает FALSE без причины.
|
||||
var full = Documents.OutputPath.Prepare(path);
|
||||
|
||||
var app = _session.Application;
|
||||
object? active = null;
|
||||
@@ -110,8 +110,8 @@ public sealed class ConversionService
|
||||
?? throw new InvalidOperationException("ConverterParameters вернул не IAdditionConvertParameters.");
|
||||
prm.Format = (ksKOMPASConverterEnum)formatCode;
|
||||
|
||||
if (!doc3d1.ConvertToAdditionFormat(path, (AdditionConvertParameters)prm))
|
||||
throw new InvalidOperationException("ConvertToAdditionFormat вернул FALSE (экспорт STEP не удался).");
|
||||
if (!doc3d1.ConvertToAdditionFormat(full, (AdditionConvertParameters)prm))
|
||||
throw new InvalidOperationException($"ConvertToAdditionFormat вернул FALSE (экспорт STEP по пути «{full}» не удался).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -64,22 +64,33 @@ public sealed class DocumentService
|
||||
}), ct);
|
||||
|
||||
/// <summary>Сохранить активный документ по указанному пути (безопасно для новых документов).</summary>
|
||||
public Task SaveAsAsync(string path, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() => WithActive(d =>
|
||||
public Task<string> SaveAsAsync(string path, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
d.SaveAs(path);
|
||||
// SaveAs ничего не возвращает, а КОМПАС молча не сохраняет, если файл с этим именем уже
|
||||
// открыт другим документом. Без проверки по факту агент получает ложный «Сохранено».
|
||||
var actual = d.PathName ?? string.Empty;
|
||||
if (!string.Equals(actual, path, StringComparison.OrdinalIgnoreCase) || !File.Exists(path))
|
||||
throw new InvalidOperationException(
|
||||
$"КОМПАС не сохранил документ по пути «{path}»" +
|
||||
(actual.Length == 0
|
||||
? " (документ так и остался несохранённым)"
|
||||
: $" (документ остался по пути «{actual}»)") +
|
||||
". Обычная причина — файл с этим именем уже открыт в КОМПАС другим документом.");
|
||||
return true;
|
||||
}), ct);
|
||||
// Каталог создаём ДО SaveAs: в отсутствующую папку КОМПАС молча не пишет.
|
||||
var full = OutputPath.Prepare(path);
|
||||
return WithActive(d =>
|
||||
{
|
||||
d.SaveAs(full);
|
||||
// SaveAs ничего не возвращает, а КОМПАС молча не сохраняет, если файл с этим именем уже
|
||||
// открыт другим документом. Без проверки по факту агент получает ложный «Сохранено».
|
||||
var actual = d.PathName ?? string.Empty;
|
||||
var samePath = string.Equals(actual, full, StringComparison.OrdinalIgnoreCase);
|
||||
if (!samePath || !File.Exists(full))
|
||||
throw new InvalidOperationException(
|
||||
$"КОМПАС не сохранил документ по пути «{full}»" +
|
||||
(actual.Length == 0
|
||||
? " (документ так и остался несохранённым)"
|
||||
: samePath
|
||||
? " (путь документу присвоен, но файла на диске нет — проверьте права на запись)"
|
||||
: $" (документ остался по пути «{actual}»)") +
|
||||
(samePath
|
||||
? "."
|
||||
: ". Обычная причина — файл с этим именем уже открыт в КОМПАС другим документом; " +
|
||||
"освободите имя через document_close(all=true)."));
|
||||
return full;
|
||||
});
|
||||
}, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Задать наименование и/или обозначение детали активного 3D-документа. Наименование — это имя
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace Kompas.Mcp.Core.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Подготовка пути, по которому мы собираемся ЧТО-ТО ЗАПИСАТЬ (документ, STEP, снимок).
|
||||
/// <para>КОМПАС каталогов не создаёт: <c>SaveAs</c> в несуществующую папку просто ничего не пишет
|
||||
/// и молчит, а <c>ConvertToAdditionFormat</c> возвращает FALSE без причины. Агент в этот момент
|
||||
/// думает, что имя файла занято, и уходит чинить не то. Поэтому каталог создаём сами, а путь
|
||||
/// приводим к абсолютному — относительный КОМПАС трактует от своего рабочего каталога,
|
||||
/// а не от каталога агента.</para>
|
||||
/// </summary>
|
||||
public static class OutputPath
|
||||
{
|
||||
/// <summary>
|
||||
/// Привести путь к абсолютному и создать каталог назначения. Возвращает полный путь.
|
||||
/// </summary>
|
||||
/// <param name="path">Путь к файлу (абсолютный или относительный).</param>
|
||||
/// <param name="parameterName">Имя параметра инструмента — попадёт в текст ошибки.</param>
|
||||
public static string Prepare(string? path, string parameterName = "path")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("Путь к файлу не задан.", parameterName);
|
||||
|
||||
string full;
|
||||
try
|
||||
{
|
||||
full = Path.GetFullPath(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
throw new ArgumentException($"Некорректный путь «{path}»: {ex.Message}", parameterName, ex);
|
||||
}
|
||||
|
||||
if (Path.GetFileName(full).Length == 0)
|
||||
throw new ArgumentException($"Путь «{path}» указывает на каталог, а не на файл.", parameterName);
|
||||
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Каталог «{directory}» не существует и не создаётся: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return full;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Runtime.Versioning;
|
||||
using Kompas6API5;
|
||||
using Kompas6Constants;
|
||||
using Kompas6Constants3D;
|
||||
@@ -10,13 +10,13 @@ namespace Kompas.Mcp.Core.Modeling;
|
||||
public sealed partial class PartModeler
|
||||
{
|
||||
/// <summary>Создать эскиз на базовой плоскости и открыть его на редактирование. Возвращает id эскиза.</summary>
|
||||
public Task<int> OpenSketchAsync(BasePlane plane, CancellationToken ct = default)
|
||||
public Task<int> OpenSketchAsync(BasePlane plane, string? name = null, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var part = GetTopPart();
|
||||
var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(plane)) as ksEntity
|
||||
?? throw new InvalidOperationException("Не удалось получить базовую плоскость.");
|
||||
return CreateSketchOn(part, planeEntity);
|
||||
return CreateSketchOn(part, planeEntity, name);
|
||||
}, ct);
|
||||
|
||||
/// <summary>
|
||||
@@ -24,7 +24,7 @@ public sealed partial class PartModeler
|
||||
/// (мировые координаты модели, мм), и открыть его на редактирование. Возвращает id эскиза.
|
||||
/// Это ключ к итеративному циклу «построил → выбрал грань → новый эскиз на ней».
|
||||
/// </summary>
|
||||
public Task<int> OpenSketchOnFaceAsync(double x, double y, double z, CancellationToken ct = default)
|
||||
public Task<int> OpenSketchOnFaceAsync(double x, double y, double z, string? name = null, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var part = GetTopPart();
|
||||
@@ -38,14 +38,14 @@ public sealed partial class PartModeler
|
||||
|
||||
var face = faces.GetByIndex(0) as ksEntity
|
||||
?? throw new InvalidOperationException("Грань из коллекции не приводится к ksEntity.");
|
||||
return CreateSketchOn(part, face);
|
||||
return CreateSketchOn(part, face, name);
|
||||
}, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Создать эскиз на грани детали, заданной индексом из list_faces, и открыть на редактирование.
|
||||
/// Индекс стабилен, пока геометрия не меняется. Возвращает id эскиза.
|
||||
/// </summary>
|
||||
public Task<int> OpenSketchOnFaceIndexAsync(int faceIndex, CancellationToken ct = default)
|
||||
public Task<int> OpenSketchOnFaceIndexAsync(int faceIndex, string? name = null, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var part = GetTopPart();
|
||||
@@ -57,21 +57,20 @@ public sealed partial class PartModeler
|
||||
|
||||
var face = faces.GetByIndex(faceIndex) as ksEntity
|
||||
?? throw new InvalidOperationException("Грань по индексу не приводится к ksEntity.");
|
||||
return CreateSketchOn(part, face);
|
||||
return CreateSketchOn(part, face, name);
|
||||
}, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Создать смещённую плоскость (база + offset мм) и открыть на ней эскиз. Возвращает id эскиза.
|
||||
/// Снимает ограничение «только базовые плоскости»: эскиз на произвольной высоте/смещении.
|
||||
/// </summary>
|
||||
public Task<int> OpenSketchOnOffsetPlaneAsync(BasePlane basePlane, double offset, bool direction = true, CancellationToken ct = default)
|
||||
public Task<int> OpenSketchOnOffsetPlaneAsync(BasePlane basePlane, double offset, bool direction = true, string? name = null, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
if (!double.IsFinite(offset))
|
||||
throw new ArgumentException("Смещение должно быть конечным числом.", nameof(offset));
|
||||
var part = GetTopPart();
|
||||
var planeEntity = part.NewEntity((short)Obj3dType.o3d_planeOffset) as ksEntity
|
||||
?? throw new InvalidOperationException("NewEntity(o3d_planeOffset) вернул null.");
|
||||
var planeEntity = NewEntity(part, Obj3dType.o3d_planeOffset, null);
|
||||
var pdef = planeEntity.GetDefinition() as ksPlaneOffsetDefinition
|
||||
?? throw new InvalidOperationException("GetDefinition() плоскости вернул не ksPlaneOffsetDefinition.");
|
||||
var baseEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(basePlane)) as ksEntity
|
||||
@@ -81,14 +80,13 @@ public sealed partial class PartModeler
|
||||
pdef.direction = direction;
|
||||
if (!planeEntity.Create())
|
||||
throw new InvalidOperationException("Create() смещённой плоскости вернул FALSE.");
|
||||
return CreateSketchOn(part, planeEntity);
|
||||
return CreateSketchOn(part, planeEntity, name);
|
||||
}, ct);
|
||||
|
||||
/// <summary>Общее ядро: создать эскиз на заданной плоскости/грани и открыть на редактирование.</summary>
|
||||
private int CreateSketchOn(ksPart part, ksEntity planeEntity)
|
||||
private int CreateSketchOn(ksPart part, ksEntity planeEntity, string? name)
|
||||
{
|
||||
var entity = part.NewEntity((short)Obj3dType.o3d_sketch) as ksEntity
|
||||
?? throw new InvalidOperationException("NewEntity(o3d_sketch) вернул null.");
|
||||
var entity = NewEntity(part, Obj3dType.o3d_sketch, name);
|
||||
var def = entity.GetDefinition() as ksSketchDefinition
|
||||
?? throw new InvalidOperationException("GetDefinition() эскиза вернул не ksSketchDefinition.");
|
||||
|
||||
@@ -176,8 +174,7 @@ public sealed partial class PartModeler
|
||||
PointCore(editor, Point(e, 0));
|
||||
break;
|
||||
case SketchEntityKind.Text:
|
||||
var length = TextCore(editor, Point(e, 0), e);
|
||||
return $"надпись «{e.Text}» — длина {length:F2} мм от точки вставки";
|
||||
return TextCore(editor, Point(e, 0), e).Describe(e.Text);
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(e), $"Неизвестный тип примитива: {e.Kind}.");
|
||||
}
|
||||
@@ -387,13 +384,31 @@ public sealed partial class PartModeler
|
||||
private const int FontItalicOn = 0x40; // ITALIC_ON
|
||||
private const int FontBoldOn = 0x100; // BOLD_ON
|
||||
|
||||
/// <summary>Габарит объекта эскиза по его reference или <c>null</c>, если КОМПАС его не отдал.
|
||||
/// Отсутствие габарита не повод рушить построение — примитив уже создан.</summary>
|
||||
private (double MinX, double MinY, double MaxX, double MaxY)? GabaritOf(ksDocument2D editor, int reference)
|
||||
{
|
||||
ksRectParam? rect = null;
|
||||
try
|
||||
{
|
||||
rect = NewParam<ksRectParam>(StructType2DEnum.ko_RectParam);
|
||||
if (editor.ksGetObjGabaritRect(reference, rect) == 0) return null;
|
||||
if (rect.GetpBot() is not ksMathPointParam bot) return null;
|
||||
if (rect.GetpTop() is not ksMathPointParam top) return null;
|
||||
try { return (bot.x, bot.y, top.x, top.y); }
|
||||
finally { ReleaseCom(bot); ReleaseCom(top); }
|
||||
}
|
||||
catch (System.Runtime.InteropServices.COMException) { return null; }
|
||||
finally { ReleaseCom(rect); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Надпись в эскизе. Текст выбранным шрифтом сразу переводится в кривые
|
||||
/// (<c>ksConvertTextToCurve</c>): пока он остаётся текстом, это оформление, и формообразующая
|
||||
/// операция его не видит. Возвращает длину строки (мм) — по ней агент выравнивает надпись,
|
||||
/// иначе ширина известна только после выдавливания.
|
||||
/// операция его не видит. Возвращает метрики надписи — по ним агент выравнивает её, иначе
|
||||
/// фактическое место, занятое буквами, известно только после выдавливания.
|
||||
/// </summary>
|
||||
private double TextCore(ksDocument2D editor, (double x, double y) at, SketchEntity e)
|
||||
private TextMetrics TextCore(ksDocument2D editor, (double x, double y) at, SketchEntity e)
|
||||
{
|
||||
var text = e.Text ?? "";
|
||||
if (text.Length == 0)
|
||||
@@ -452,10 +467,13 @@ public sealed partial class PartModeler
|
||||
throw new InvalidOperationException(
|
||||
$"ksTextEx вернул 0 (надпись не создана — шрифт «{e.FontName}» установлен в системе?).");
|
||||
|
||||
var length = editor.ksGetTextLengthFromReference(reference);
|
||||
if (editor.ksConvertTextToCurve(reference) == 0)
|
||||
var advance = editor.ksGetTextLengthFromReference(reference);
|
||||
var curves = editor.ksConvertTextToCurve(reference);
|
||||
if (curves == 0)
|
||||
throw new InvalidOperationException("ksConvertTextToCurve вернул 0 (надпись не переведена в кривые).");
|
||||
return length;
|
||||
// Габарит берём по ссылке на КРИВЫЕ, которую вернула конвертация: у исходного текстового
|
||||
// объекта рамка идёт по ячейке строки, а она шире глифов (на скриптовых шрифтах — до 8 %).
|
||||
return new TextMetrics(advance, GabaritOf(editor, curves));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Kompas.Mcp.Core.Modeling;
|
||||
|
||||
/// <summary>
|
||||
/// Метрики построенной надписи. Агенту нужны ДВЕ разные величины, и путать их дорого:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Габарит глифов</b> — прямоугольник, реально занятый буквами. По нему
|
||||
/// выравнивают надпись и вписывают её в поле; только он совпадает с тем, что получится после
|
||||
/// выдавливания.</description></item>
|
||||
/// <item><description><b>Ширина ячейки</b> (<c>ksGetTextLengthFromReference</c>) — шаг строки
|
||||
/// с боковыми выносами. Больше габарита на 1–2 % у наборных шрифтов и на 7–9 % у скриптовых.</description></item>
|
||||
/// </list>
|
||||
/// Пока в ответе была только ячейка, подгонка надписи под размер шла вслепую: вставить →
|
||||
/// выдавить → замерить → пересобрать документ.
|
||||
/// </summary>
|
||||
/// <param name="Advance">Ширина ячейки строки, мм.</param>
|
||||
/// <param name="Box">Габарит глифов в координатах эскиза (мм) или <c>null</c>, если КОМПАС его не отдал.</param>
|
||||
public readonly record struct TextMetrics(
|
||||
double Advance,
|
||||
(double MinX, double MinY, double MaxX, double MaxY)? Box)
|
||||
{
|
||||
/// <summary>Заметка для ответа инструмента: где легли буквы и сколько заняли.</summary>
|
||||
public string Describe(string? text)
|
||||
{
|
||||
var what = string.IsNullOrEmpty(text) ? "надпись" : $"надпись «{text}»";
|
||||
var cell = F(Advance);
|
||||
if (Box is not { } b)
|
||||
return $"{what} — ширина ячейки {cell} мм (габарит глифов КОМПАС не отдал)";
|
||||
|
||||
var width = F(b.MaxX - b.MinX);
|
||||
var height = F(b.MaxY - b.MinY);
|
||||
return $"{what} — глифы X {F(b.MinX)}…{F(b.MaxX)}, Y {F(b.MinY)}…{F(b.MaxY)} " +
|
||||
$"({width} × {height} мм), ширина ячейки {cell} мм";
|
||||
}
|
||||
|
||||
private static string F(double value) => value.ToString("F2", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Runtime.Versioning;
|
||||
using Kompas.Mcp.Core.Threading;
|
||||
using Kompas6API5;
|
||||
|
||||
@@ -63,8 +63,9 @@ public sealed class SnapshotService
|
||||
// вывод работает надёжно.
|
||||
param.returnResultAsArrayBytes = false;
|
||||
|
||||
var outFile = saveToFile ?? Path.Combine(
|
||||
Path.GetTempPath(), $"kompas-snap-{Guid.NewGuid():N}.{format.FileExtension()}");
|
||||
var outFile = saveToFile is null
|
||||
? Path.Combine(Path.GetTempPath(), $"kompas-snap-{Guid.NewGuid():N}.{format.FileExtension()}")
|
||||
: Documents.OutputPath.Prepare(saveToFile, nameof(saveToFile));
|
||||
var deleteAfter = saveToFile is null;
|
||||
|
||||
try
|
||||
|
||||
@@ -47,8 +47,8 @@ public sealed class DocumentTools(KompasSession session, DocumentService docs, P
|
||||
return "Документ сохранён.";
|
||||
}
|
||||
|
||||
await docs.SaveAsAsync(path);
|
||||
return $"Сохранено: {path}";
|
||||
var saved = await docs.SaveAsAsync(path);
|
||||
return $"Сохранено: {saved}";
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "document_close")]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel;
|
||||
using Kompas.Mcp.Core;
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
using ModelContextProtocol.Server;
|
||||
@@ -17,7 +17,8 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
|
||||
"на грани в мировых координатах модели (запасной вариант, когда индекс не подходит). " +
|
||||
"entities — список примитивов, autoClose=true закрывает эскиз сразу, чтобы можно было звать " +
|
||||
"extrude/revolve. Без entities эскиз остаётся открытым — наполняйте его через sketch_add. " +
|
||||
"Возвращает id эскиза.")]
|
||||
"name — имя эскиза в дереве построения («Контур плашки»): давайте его ВСЕГДА, иначе дерево " +
|
||||
"состоит из безликих «Эскиз:1…N» и по нему нельзя понять, что где. Возвращает id эскиза.")]
|
||||
public async Task<string> Create(
|
||||
[Description("Базовая плоскость: XOY | XOZ | YOZ")] string? plane = null,
|
||||
[Description("Индекс грани из list_faces (надёжнее выбора по точке)")] int? faceIndex = null,
|
||||
@@ -27,7 +28,8 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
|
||||
[Description("Смещение от базовой плоскости, мм (только вместе с plane)")] double offset = 0,
|
||||
[Description("Прямое направление смещения от базовой плоскости")] bool offsetDirection = true,
|
||||
[Description("Примитивы эскиза: список объектов {type, points, centerX, ...}")] SketchEntityInput[]? entities = null,
|
||||
[Description("Закрыть эскиз после построения entities (нужно перед операцией)")] bool autoClose = true)
|
||||
[Description("Закрыть эскиз после построения entities (нужно перед операцией)")] bool autoClose = true,
|
||||
[Description("Имя эскиза в дереве построения, например «Контур плашки»")] string? name = null)
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var parsed = SketchEntityInputs.Parse(entities);
|
||||
@@ -36,12 +38,12 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
|
||||
string on;
|
||||
if (faceIndex is { } fi)
|
||||
{
|
||||
id = await modeler.OpenSketchOnFaceIndexAsync(fi);
|
||||
id = await modeler.OpenSketchOnFaceIndexAsync(fi, name);
|
||||
on = $"грани [{fi}]";
|
||||
}
|
||||
else if (x is { } px && y is { } py && z is { } pz)
|
||||
{
|
||||
id = await modeler.OpenSketchOnFaceAsync(px, py, pz);
|
||||
id = await modeler.OpenSketchOnFaceAsync(px, py, pz, name);
|
||||
on = $"грани в точке ({px}, {py}, {pz})";
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(plane))
|
||||
@@ -49,12 +51,12 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
|
||||
var basePlane = BasePlanes.Parse(plane);
|
||||
if (offset == 0)
|
||||
{
|
||||
id = await modeler.OpenSketchAsync(basePlane);
|
||||
id = await modeler.OpenSketchAsync(basePlane, name);
|
||||
on = $"плоскости {plane}";
|
||||
}
|
||||
else
|
||||
{
|
||||
id = await modeler.OpenSketchOnOffsetPlaneAsync(basePlane, offset, offsetDirection);
|
||||
id = await modeler.OpenSketchOnOffsetPlaneAsync(basePlane, offset, offsetDirection, name);
|
||||
on = $"плоскости {plane}, смещение {offset} мм";
|
||||
}
|
||||
}
|
||||
@@ -95,7 +97,7 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
|
||||
return $"В эскиз id={sketchId} добавлено примитивов: {parsed.Count}.{state}{Notes(notes)}";
|
||||
}
|
||||
|
||||
/// <summary>Заметки по построенным примитивам (сейчас — фактическая длина надписей).</summary>
|
||||
/// <summary>Заметки по построенным примитивам (сейчас — метрики надписей: габарит глифов и ячейка).</summary>
|
||||
private static string Notes(IReadOnlyList<string> notes)
|
||||
{
|
||||
var meaningful = notes.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||||
|
||||
@@ -52,6 +52,30 @@ public sealed class DocumentTests : IntegrationTestBase
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAs_creates_the_missing_directory()
|
||||
{
|
||||
// КОМПАС каталогов не создаёт: SaveAs в несуществующую папку молча ничего не пишет,
|
||||
// а прежняя диагностика винила «занятое имя файла» — агент чинил не ту причину.
|
||||
var path = Path.Combine(Path.GetDirectoryName(TestPaths.NewFile(".m3d"))!,
|
||||
"подпапка кейса", "деталь.m3d");
|
||||
Assert.False(Directory.Exists(Path.GetDirectoryName(path)));
|
||||
|
||||
await _docs.CreateAsync(KompasDocumentType.Part);
|
||||
try
|
||||
{
|
||||
var saved = await _docs.SaveAsAsync(path);
|
||||
|
||||
Assert.Equal(path, saved, ignoreCase: true);
|
||||
Assert.True(File.Exists(path), "файл не появился на диске");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _docs.CloseAsync(save: false);
|
||||
try { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Part_name_and_marking_survive_the_write()
|
||||
{
|
||||
|
||||
@@ -44,8 +44,9 @@ public sealed class SketchTextTests : IntegrationTestBase
|
||||
await _modeler.ExtrudeAsync(s, depth: 2);
|
||||
await _modeler.RebuildAsync();
|
||||
|
||||
// Длина строки нужна агенту для выравнивания: до выдавливания её узнать неоткуда.
|
||||
Assert.Contains("длина", notes[0], StringComparison.Ordinal);
|
||||
// Метрики нужны агенту для выравнивания: до выдавливания их узнать неоткуда.
|
||||
Assert.Contains("глифы", notes[0], StringComparison.Ordinal);
|
||||
Assert.Contains("ширина ячейки", notes[0], StringComparison.Ordinal);
|
||||
|
||||
var info = await _query.GetPartInfoAsync();
|
||||
Assert.True(info.Volume > 0, "надпись не дала объёма — текст не переведён в кривые");
|
||||
@@ -58,6 +59,36 @@ public sealed class SketchTextTests : IntegrationTestBase
|
||||
finally { await _docs.CloseAsync(save: false); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reported_glyph_box_matches_the_extruded_geometry()
|
||||
{
|
||||
await _docs.CreateAsync(KompasDocumentType.Part);
|
||||
try
|
||||
{
|
||||
// Ширина ячейки (её и возвращали раньше) больше габарита глифов, поэтому агент,
|
||||
// вписывающий надпись в поле, промахивался. Сверяем заявленный габарит с фактическим.
|
||||
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
|
||||
var notes = await _modeler.AddEntitiesAsync(s, [Text("HH", 0, 0, 10, "Arial")], autoClose: true);
|
||||
await _modeler.ExtrudeAsync(s, depth: 2);
|
||||
await _modeler.RebuildAsync();
|
||||
|
||||
var numbers = System.Text.RegularExpressions.Regex
|
||||
.Matches(notes[0], @"-?\d+\.\d{2}")
|
||||
.Select(m => double.Parse(m.Value, System.Globalization.CultureInfo.InvariantCulture))
|
||||
.ToList();
|
||||
Assert.True(numbers.Count >= 7, $"в заметке нет чисел габарита: {notes[0]}");
|
||||
var (minX, maxX, minY, maxY) = (numbers[0], numbers[1], numbers[2], numbers[3]);
|
||||
|
||||
var box = await _query.GetBoundingBoxAsync();
|
||||
Assert.InRange(maxX - minX, box.SizeX - 0.05, box.SizeX + 0.05);
|
||||
Assert.InRange(maxY - minY, box.SizeY - 0.05, box.SizeY + 0.05);
|
||||
|
||||
// Единицы — миллиметры эскиза, а не сантиметры листа: высота прописной ≈ height.
|
||||
Assert.InRange(maxY - minY, 9.5, 10.5);
|
||||
}
|
||||
finally { await _docs.CloseAsync(save: false); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_font_is_reported_by_name()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
|
||||
namespace Kompas.Mcp.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Подготовка пути записи. КОМПАС в несуществующую папку молча не пишет, а инструмент рапортовал
|
||||
/// «Сохранено» либо винил занятое имя файла — агент уходил чинить не ту причину.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OutputPathTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "kompas-mcp-tests", Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
[Fact]
|
||||
public void Missing_directory_is_created()
|
||||
{
|
||||
var path = Path.Combine(_root, "вложенная папка", "деталь.m3d");
|
||||
|
||||
var full = OutputPath.Prepare(path);
|
||||
|
||||
Assert.Equal(path, full);
|
||||
Assert.True(Directory.Exists(Path.GetDirectoryName(full)), "каталог назначения не создан");
|
||||
Assert.False(File.Exists(full), "сам файл создавать не нужно — его пишет КОМПАС");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Relative_path_becomes_absolute()
|
||||
{
|
||||
var full = OutputPath.Prepare("деталь.m3d");
|
||||
|
||||
Assert.True(Path.IsPathRooted(full), "относительный путь КОМПАС трактует от своего каталога");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Empty_path_is_rejected(string? path)
|
||||
=> Assert.Throws<ArgumentException>(() => OutputPath.Prepare(path));
|
||||
|
||||
[Fact]
|
||||
public void Directory_without_a_file_name_is_rejected()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => OutputPath.Prepare(_root + Path.DirectorySeparatorChar));
|
||||
Assert.Contains("каталог", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Existing_directory_is_left_alone()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
var marker = Path.Combine(_root, "уже был.txt");
|
||||
File.WriteAllText(marker, "x");
|
||||
|
||||
OutputPath.Prepare(Path.Combine(_root, "деталь.m3d"));
|
||||
|
||||
Assert.True(File.Exists(marker), "подготовка пути не должна трогать содержимое каталога");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); }
|
||||
catch (IOException) { /* временный каталог подчистит система */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
|
||||
namespace Kompas.Mcp.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ответ по надписи. Агент вписывает текст в заданное поле, поэтому ему нужен габарит ГЛИФОВ;
|
||||
/// ширина ячейки (ksGetTextLengthFromReference) больше него на 1–2 % у наборных шрифтов и на
|
||||
/// 7–9 % у скриптовых — подстановка одного вместо другого промахивается мимо поля.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TextMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Box_and_cell_are_both_reported()
|
||||
{
|
||||
var m = new TextMetrics(94.57, (1.0, 12.14, 87.93, 28.94));
|
||||
|
||||
var text = m.Describe("OldMan");
|
||||
|
||||
Assert.Contains("OldMan", text, StringComparison.Ordinal);
|
||||
Assert.Contains("1.00…87.93", text, StringComparison.Ordinal); // габарит по X
|
||||
Assert.Contains("12.14…28.94", text, StringComparison.Ordinal); // габарит по Y
|
||||
Assert.Contains("86.93 × 16.80", text, StringComparison.Ordinal); // ширина × высота глифов
|
||||
Assert.Contains("94.57", text, StringComparison.Ordinal); // ячейка — отдельно
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_box_is_stated_instead_of_being_faked()
|
||||
{
|
||||
var text = new TextMetrics(50.0, null).Describe("EDITION");
|
||||
|
||||
Assert.Contains("50.00", text, StringComparison.Ordinal);
|
||||
Assert.Contains("не отдал", text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decimal_separator_does_not_depend_on_the_machine_locale()
|
||||
{
|
||||
var text = new TextMetrics(1.5, (0, 0, 1, 2)).Describe("x");
|
||||
|
||||
// Запятая в строке есть — она разделяет оси; проверяем именно дробную часть числа.
|
||||
Assert.Contains("1.50", text, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("1,50", text, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user