Формообразование: надписи в эскизе, тонкая стенка, тела по размерам

Три способа получить форму там, где раньше был только замкнутый контур
из отрезков и дуг.

1. Примитив эскиза type=text: ksTextEx + ksConvertTextToCurve. Пока текст
   остаётся текстом, это оформление, и операция его не видит; после
   конвертации глифы становятся обычным сечением. Ответ возвращает
   фактическую длину строки — иначе ширину шрифта до выдавливания не узнать.

2. extrude(thinThickness, thinSide): контур трактуется как стенка заданной
   толщины. outward по замкнутому контуру даёт кайму-эквидистанту вокруг
   него — так надпись получает подложку без 2D-эквидистанты.
   Толщину при dtReverse КОМПАС читает из reverseThickness: положить её в
   normalThickness значит молча получить СПЛОШНОЕ сечение вместо рамки.

3. Инструмент primitive: элементарные тела API7 (block, cylinder, sphere,
   cone) с result=new|union|subtract|intersect. Карман и паз строятся
   вычитанием тела, без эскиза и выреза. Update() возвращает TRUE даже
   когда вычитание прошло мимо тела, поэтому сервис сверяет объём до и
   после и откатывает операцию, если ничего не изменилось.
This commit is contained in:
2026-07-31 14:50:56 +03:00
parent dfe5636290
commit a4cc755fb4
15 changed files with 941 additions and 21 deletions
@@ -14,13 +14,19 @@ public sealed partial class PartModeler
/// </summary>
/// <param name="throughAll">Сквозное выдавливание/вырез через всю деталь в обе стороны
/// (для отверстий — гарантированно пробивает материал; <paramref name="depth"/> игнорируется).</param>
/// <param name="thinThickness">Толщина тонкой стенки, мм: контур эскиза трактуется не как граница
/// сплошного сечения, а как средняя/крайняя линия стенки такой толщины. 0 — сплошное тело.</param>
/// <param name="thinSide">Куда откладывать толщину от контура: Outward | Inward | Both.</param>
public Task<int> ExtrudeAsync(
int sketchId, double depth, bool cut = false, bool forward = true,
double draftAngle = 0, bool throughAll = false, CancellationToken ct = default)
double draftAngle = 0, bool throughAll = false,
double thinThickness = 0, ThinSide thinSide = ThinSide.Outward, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!throughAll && depth <= 0)
throw new ArgumentOutOfRangeException(nameof(depth), "Глубина должна быть > 0 (или используйте throughAll).");
if (thinThickness < 0)
throw new ArgumentOutOfRangeException(nameof(thinThickness), "Толщина стенки должна быть >= 0.");
var sketch = RequireSketch(sketchId);
CloseSketchCore(sketch); // эскиз должен быть закрыт перед операцией
@@ -45,6 +51,8 @@ public sealed partial class PartModeler
def.directionType = (short)Direction_Type.dtNormal;
def.SetSideParam(forward, (short)End_Type.etBlind, depth, draftAngle, false);
}
var (thin, thinType, normal, reverse) = ThinParams(thinThickness, thinSide);
def.SetThinParam(thin, thinType, normal, reverse);
def.SetSketch(sketch.Entity);
}
else
@@ -55,17 +63,38 @@ public sealed partial class PartModeler
?? throw new InvalidOperationException("GetDefinition() выдавливания вернул не ksBossExtrusionDefinition.");
def.directionType = (short)Direction_Type.dtNormal;
def.SetSideParam(forward, (short)End_Type.etBlind, depth, draftAngle, false);
var (thin, thinType, normal, reverse) = ThinParams(thinThickness, thinSide);
def.SetThinParam(thin, thinType, normal, reverse);
def.SetSketch(sketch.Entity);
}
if (!entity.Create())
throw new InvalidOperationException("Create() операции выдавливания вернул FALSE.");
throw new InvalidOperationException(
"Create() операции выдавливания вернул FALSE." + (thinThickness > 0
? " Тонкая стенка по многоконтурному эскизу (например, по надписи) не строится, " +
"если пересекает уже созданное тело: постройте её ДО этого тела либо соберите " +
"форму через primitive(result=union|subtract)."
: " Эскиз пуст, контур не замкнут или операция не пересекает тело?"));
var id = _nextId++;
_features[id] = entity;
return id;
}, ct);
/// <summary>Разложить толщину стенки в параметры ksBoss/CutExtrusionDefinition.SetThinParam.</summary>
private static (bool thin, short thinType, double normal, double reverse) ThinParams(double thickness, ThinSide side)
{
if (thickness <= 0) return (false, 0, 0, 0);
// Толщина читается из того поля, которое отвечает выбранному направлению: при dtReverse
// normalThickness игнорируется, и стенка нулевой толщины молча даёт СПЛОШНОЕ сечение.
return side switch
{
ThinSide.Inward => (true, (short)Direction_Type.dtReverse, 0, thickness),
ThinSide.Both => (true, (short)Direction_Type.dtBoth, thickness / 2, thickness / 2),
_ => (true, (short)Direction_Type.dtNormal, thickness, 0),
};
}
/// <summary>
/// Вращать эскиз вокруг его осевой линии (добавьте её через AddAxis). <paramref name="cut"/> —
/// вырезание вместо добавления материала. Угол <paramref name="angle"/> — в градусах.
@@ -108,21 +108,23 @@ public sealed partial class PartModeler
/// Добавить пакет примитивов в открытый эскиз за ОДИН заход на STA-поток. Целый контур
/// (десяток отрезков и окружностей) обходится одним COM-сеансом вместо вызова на примитив.
/// <paramref name="autoClose"/> — завершить редактирование эскиза сразу после построения.
/// Возвращает число добавленных примитивов.
/// Возвращает по элементу на примитив: заметку о построенном (пустая строка — сказать нечего,
/// у надписи это её фактическая длина, которую иначе не узнать до выдавливания).
/// </summary>
public Task<int> AddEntitiesAsync(int sketchId, IReadOnlyList<SketchEntity> entities, bool autoClose = false, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
public Task<IReadOnlyList<string>> AddEntitiesAsync(int sketchId, IReadOnlyList<SketchEntity> entities, bool autoClose = false, CancellationToken ct = default)
=> _dispatcher.InvokeAsync<IReadOnlyList<string>>(() =>
{
ArgumentNullException.ThrowIfNull(entities);
var sketch = RequireOpenSketch(sketchId);
var editor = sketch.Editor!;
var notes = new string[entities.Count];
for (int i = 0; i < entities.Count; i++)
{
var e = entities[i] ?? throw new ArgumentException($"Примитив [{i}] пустой.", nameof(entities));
try
{
AddEntityCore(editor, e);
notes[i] = AddEntityCore(editor, e);
}
catch (Exception ex)
{
@@ -133,10 +135,10 @@ public sealed partial class PartModeler
}
if (autoClose) CloseSketchCore(sketch);
return entities.Count;
return notes;
}, ct);
private void AddEntityCore(ksDocument2D editor, SketchEntity e)
private string AddEntityCore(ksDocument2D editor, SketchEntity e)
{
switch (e.Kind)
{
@@ -173,9 +175,13 @@ public sealed partial class PartModeler
case SketchEntityKind.Point:
PointCore(editor, Point(e, 0));
break;
case SketchEntityKind.Text:
var length = TextCore(editor, Point(e, 0), e);
return $"надпись «{e.Text}» — длина {length:F2} мм от точки вставки";
default:
throw new ArgumentOutOfRangeException(nameof(e), $"Неизвестный тип примитива: {e.Kind}.");
}
return "";
}
/// <summary>Точка пакета по позиции — с внятной ошибкой вместо IndexOutOfRange.</summary>
@@ -374,4 +380,93 @@ public sealed partial class PartModeler
if (editor.ksPoint(p.x, p.y, PointStyle) == 0)
throw new InvalidOperationException("ksPoint вернул 0 (точка не создана).");
}
// Типы динамических массивов API5 (ldefin2d.h) и биты начертания шрифта.
private const int TextLineArrType = 3; // TEXT_LINE_ARR — строки надписи
private const int TextItemArrType = 4; // TEXT_ITEM_ARR — компоненты строки
private const int FontItalicOn = 0x40; // ITALIC_ON
private const int FontBoldOn = 0x100; // BOLD_ON
/// <summary>
/// Надпись в эскизе. Текст выбранным шрифтом сразу переводится в кривые
/// (<c>ksConvertTextToCurve</c>): пока он остаётся текстом, это оформление, и формообразующая
/// операция его не видит. Возвращает длину строки (мм) — по ней агент выравнивает надпись,
/// иначе ширина известна только после выдавливания.
/// </summary>
private double TextCore(ksDocument2D editor, (double x, double y) at, SketchEntity e)
{
var text = e.Text ?? "";
if (text.Length == 0)
throw new ArgumentException("Пустая надпись: задайте text.");
SketchGeometry.RequirePositive(e.Height, "height");
SketchGeometry.RequirePositive(e.WidthFactor, "widthFactor");
ksTextParam? param = null;
ksTextItemParam? item = null;
ksTextLineParam? line = null;
ksDynamicArray? items = null;
ksDynamicArray? lines = null;
try
{
param = NewParam<ksTextParam>(StructType2DEnum.ko_TextParam);
param.Init();
if (param.GetParagraphParam() is not ksParagraphParam paragraph)
throw new InvalidOperationException("GetParagraphParam() вернул не ksParagraphParam.");
paragraph.Init();
paragraph.x = at.x;
paragraph.y = at.y;
paragraph.ang = e.Angle;
paragraph.height = e.Height;
paragraph.width = 0;
param.SetParagraphParam(paragraph);
item = NewParam<ksTextItemParam>(StructType2DEnum.ko_TextItemParam);
item.Init();
item.type = (int)ksTextItemEnum.ksTItString;
item.s = text;
if (item.GetItemFont() is not ksTextItemFont font)
throw new InvalidOperationException("GetItemFont() вернул не ksTextItemFont.");
font.Init();
if (!string.IsNullOrWhiteSpace(e.FontName)) font.fontName = e.FontName;
font.height = e.Height;
font.ksu = e.WidthFactor;
font.bitVector = (e.Bold ? FontBoldOn : 0) | (e.Italic ? FontItalicOn : 0);
item.SetItemFont(font);
items = _session.Kompas.GetDynamicArray(TextItemArrType) as ksDynamicArray
?? throw new InvalidOperationException("GetDynamicArray(TEXT_ITEM_ARR) вернул null.");
items.ksAddArrayItem(-1, item);
line = NewParam<ksTextLineParam>(StructType2DEnum.ko_TextLineParam);
line.Init();
line.style = MainLineStyle;
line.SetTextItemArr(items);
lines = _session.Kompas.GetDynamicArray(TextLineArrType) as ksDynamicArray
?? throw new InvalidOperationException("GetDynamicArray(TEXT_LINE_ARR) вернул null.");
lines.ksAddArrayItem(-1, line);
param.SetTextLineArr(lines);
var reference = editor.ksTextEx(param, 0);
if (reference == 0)
throw new InvalidOperationException(
$"ksTextEx вернул 0 (надпись не создана — шрифт «{e.FontName}» установлен в системе?).");
var length = editor.ksGetTextLengthFromReference(reference);
if (editor.ksConvertTextToCurve(reference) == 0)
throw new InvalidOperationException("ksConvertTextToCurve вернул 0 (надпись не переведена в кривые).");
return length;
}
finally
{
// Динамические массивы живут в КОМПАС, а не в CLR: без ksDeleteArray они останутся в памяти.
lines?.ksDeleteArray();
items?.ksDeleteArray();
ReleaseCom(lines);
ReleaseCom(items);
ReleaseCom(line);
ReleaseCom(item);
ReleaseCom(param);
}
}
}
@@ -0,0 +1,103 @@
using Kompas6Constants3D;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>Элементарное тело («примитив»): строится без эскиза, по размерам.</summary>
public enum PrimitiveKind
{
Block,
Cylinder,
Sphere,
Cone,
}
/// <summary>Как результат примитива сочетается с уже построенными телами.</summary>
public enum PrimitiveResult
{
/// <summary>Отдельное новое тело (объединять потом вручную).</summary>
NewBody,
/// <summary>Объединить с телом, которое примитив пересекает.</summary>
Union,
/// <summary>Вычесть из тела — карман/паз без эскиза и выреза.</summary>
Subtract,
/// <summary>Оставить только пересечение.</summary>
Intersect,
}
public static class Primitives
{
public static PrimitiveKind ParseKind(string? value) => (value ?? "").Trim().ToLowerInvariant() switch
{
"block" or "box" => PrimitiveKind.Block,
"cylinder" => PrimitiveKind.Cylinder,
"sphere" => PrimitiveKind.Sphere,
"cone" => PrimitiveKind.Cone,
_ => throw new ArgumentException(
$"Неизвестный примитив '{value}'. Допустимо: block | cylinder | sphere | cone.", nameof(value)),
};
public static PrimitiveResult ParseResult(string? value) => (value ?? "").Trim().ToLowerInvariant() switch
{
"" or "new" or "newbody" or "new_body" => PrimitiveResult.NewBody,
"union" or "add" => PrimitiveResult.Union,
"subtract" or "cut" => PrimitiveResult.Subtract,
"intersect" => PrimitiveResult.Intersect,
_ => throw new ArgumentException(
$"Неизвестный режим '{value}'. Допустимо: new | union | subtract | intersect.", nameof(value)),
};
public static ksOperationResultEnum ToOperationResult(PrimitiveResult result) => result switch
{
PrimitiveResult.Union => ksOperationResultEnum.ksOperationUnion,
PrimitiveResult.Subtract => ksOperationResultEnum.ksOperationCut,
PrimitiveResult.Intersect => ksOperationResultEnum.ksOperationIntersect,
_ => ksOperationResultEnum.ksOperationNewBody,
};
/// <summary>Тип объекта для <c>IElementaryBodies.Add</c> — именно ksObj3dTypeEnum, не Obj3dType
/// (значения совпадают, но API7 принимает только этот enum).</summary>
public static ksObj3dTypeEnum ToObj3dType(PrimitiveKind kind) => kind switch
{
PrimitiveKind.Block => ksObj3dTypeEnum.o3d_BlockBySizes,
PrimitiveKind.Cylinder => ksObj3dTypeEnum.o3d_Cylinder,
PrimitiveKind.Sphere => ksObj3dTypeEnum.o3d_Sphere,
PrimitiveKind.Cone => ksObj3dTypeEnum.o3d_ConeByHeight,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Неизвестный примитив."),
};
/// <summary>
/// Проверить набор размеров под конкретный примитив. Схема инструмента не может запретить
/// неверную комбинацию полей, поэтому ошибка обязана называть kind и недостающий параметр.
/// </summary>
public static void Validate(PrimitiveKind kind, double? length, double? width, double? height, double? diameter)
{
switch (kind)
{
case PrimitiveKind.Block:
Positive(length, "length", kind);
Positive(width, "width", kind);
Positive(height, "height", kind);
break;
case PrimitiveKind.Cylinder:
case PrimitiveKind.Cone:
Positive(diameter, "diameter", kind);
Positive(height, "height", kind);
break;
case PrimitiveKind.Sphere:
Positive(diameter, "diameter", kind);
break;
}
}
private static void Positive(double? value, string name, PrimitiveKind kind)
{
if (value is null)
throw new ArgumentException($"kind={kind.ToString().ToLowerInvariant()} требует параметр {name}.");
if (!double.IsFinite(value.Value) || value.Value <= 0)
throw new ArgumentException(
$"kind={kind.ToString().ToLowerInvariant()}: {name} должен быть конечным числом > 0 (передано {value}).");
}
}
@@ -0,0 +1,173 @@
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Threading;
using Kompas6Constants3D;
using KompasAPI7;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>
/// Элементарные тела API7 (<c>IElementaryBodies</c>): параллелепипед, цилиндр, сфера, конус.
/// Это второй способ формообразования рядом с «эскиз → выдавливание»: тело задаётся размерами,
/// без эскиза, и сразу может объединяться/вычитаться из существующего
/// (<see cref="PrimitiveResult"/> → <c>ksOperationResultEnum</c>).
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class PrimitiveService
{
private readonly KompasSession _session;
private readonly KompasDispatcher _dispatcher;
public PrimitiveService(KompasSession session, KompasDispatcher dispatcher)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
}
/// <summary>
/// Построить примитив в точке (<paramref name="x"/>,<paramref name="y"/>,<paramref name="z"/>) —
/// для блока это угол, для остальных центр основания. Размеры — по осям текущей системы координат:
/// length по X, width по Y, height по Z.
/// </summary>
public Task PrimitiveAsync(
PrimitiveKind kind, double x, double y, double z,
double? length = null, double? width = null, double? height = null, double? diameter = null,
PrimitiveResult result = PrimitiveResult.NewBody, bool forward = true, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
Primitives.Validate(kind, length, width, height, diameter);
if (!double.IsFinite(x) || !double.IsFinite(y) || !double.IsFinite(z))
throw new ArgumentException("Координаты точки привязки должны быть конечными числами.");
if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d)
throw new InvalidOperationException("Активный документ не является 3D-документом.");
if (doc3d.TopPart is not IPart7 top)
throw new InvalidOperationException("Не удалось получить TopPart активного документа.");
if (top is not IModelContainer container)
throw new InvalidOperationException("Деталь не приводится к IModelContainer.");
var bodies = container.ElementaryBodies
?? throw new InvalidOperationException("ElementaryBodies недоступны (нужен КОМПАС v21 и новее).");
var before = CurrentVolume();
var body = bodies.Add(Primitives.ToObj3dType(kind))
?? throw new InvalidOperationException($"ElementaryBodies.Add({kind}) вернул null.");
try
{
var elementary = (IElementaryBody)body;
elementary.OperationResult = Primitives.ToOperationResult(result);
Place(elementary, x, y, z);
Configure(body, kind, length, width, height, diameter, forward);
if (!elementary.Update())
throw new InvalidOperationException(
$"Update() примитива вернул FALSE: {Explain(result)}");
RequireVolumeChanged(before, result);
}
catch
{
// Неудачный примитив не должен оставаться в дереве «сиротой».
try { ((IElementaryBody)body).Owner?.Delete(); } catch { /* лучшее усилие */ }
throw;
}
finally { ComHelper.Release(body); ComHelper.Release(bodies); }
}, ct);
/// <summary>Точка привязки примитива задаётся его локальной системой координат.</summary>
private static void Place(IElementaryBody body, double x, double y, double z)
{
var position = body.Position
?? throw new InvalidOperationException("Position примитива недоступна.");
position.X = x;
position.Y = y;
position.Z = z;
}
private static void Configure(
object body, PrimitiveKind kind,
double? length, double? width, double? height, double? diameter, bool forward)
{
var direction = forward ? ksDirectionTypeEnum.dtNormal : ksDirectionTypeEnum.dtReverse;
// Height/HeightType/Diameter у примитивов — ИНДЕКСИРОВАННЫЕ свойства: индекс [true] это
// прямая сторона, [false] — обратная (для двунаправленных построений). Без индекса не компилируется.
const bool normalSide = true;
switch (kind)
{
case PrimitiveKind.Block:
var block = (IBlockBySizes)body;
block.BlockPosition = ksBlockPositionTypeEnum.ksVertex; // точка привязки — угол
block.Length = length!.Value;
block.Width = width!.Value;
block.Height[normalSide] = height!.Value;
block.LengthDirection = true;
block.WidthDirection = true;
block.HeightDirection = direction;
block.HeightType[normalSide] = ksEndTypeEnum.etBlind;
break;
case PrimitiveKind.Cylinder:
var cylinder = (ICylinder)body;
cylinder.Diameter = diameter!.Value;
cylinder.Height[normalSide] = height!.Value;
cylinder.HeightDirection = direction;
cylinder.HeightType[normalSide] = ksEndTypeEnum.etBlind;
break;
case PrimitiveKind.Sphere:
((ISphere)body).Diameter = diameter!.Value;
break;
case PrimitiveKind.Cone:
var cone = (IConeByHeight)body;
cone.BaseDiameter = diameter!.Value;
cone.Diameter[normalSide] = 0; // вершина сведена в точку — полный конус
cone.Height[normalSide] = height!.Value;
cone.ConeDirection = direction;
cone.HeightType[normalSide] = ksEndTypeEnum.etBlind;
break;
}
}
/// <summary>
/// Update()==TRUE ещё не значит, что примитив что-то изменил: вычитание мимо тела КОМПАС
/// считает успехом и молча оставляет модель как была. Проверяем по объёму.
/// </summary>
private void RequireVolumeChanged(double before, PrimitiveResult result)
{
const double eps = 1e-6;
var after = CurrentVolume();
switch (result)
{
case PrimitiveResult.Subtract when after > before - eps:
throw new InvalidOperationException(
"Вычитание примитива не удалило материал: тело не пересекается с ним. " +
"Сверьте точку привязки и размеры (для блока x,y,z — это УГОЛ, а не центр).");
case PrimitiveResult.Intersect when Math.Abs(after - before) < eps:
throw new InvalidOperationException(
"Пересечение с примитивом не изменило тело — примитив накрывает его целиком или не задевает.");
case PrimitiveResult.NewBody or PrimitiveResult.Union when after < before + eps:
throw new InvalidOperationException(
"Примитив не добавил материала: нулевые размеры или он целиком внутри существующего тела.");
}
}
/// <summary>Текущий объём детали (мм³) через API5 МЦХ — та же метрика, что у QueryService.</summary>
private double CurrentVolume()
{
const int mixMmKg = 0x1 | 0x10; // ST_MIX_MM | ST_MIX_KG
var doc3d = _session.Kompas.ActiveDocument3D() as Kompas6API5.ksDocument3D
?? throw new InvalidOperationException("Нет активного 3D-документа (API5).");
var part = doc3d.GetPart((short)Kompas6Constants3D.Part_Type.pTop_Part) as Kompas6API5.ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
var mass = part.CalcMassInertiaProperties(mixMmKg) as Kompas6API5.ksMassInertiaParam;
return mass?.v ?? 0;
}
/// <summary>Update() у примитива возвращает FALSE молча — подсказываем вероятную причину.</summary>
private static string Explain(PrimitiveResult result) => result switch
{
PrimitiveResult.Subtract => "вычитание не пересекает ни одно тело или удалило бы его целиком?",
PrimitiveResult.Union => "объединять не с чем — примитив не касается существующих тел?",
PrimitiveResult.Intersect => "пересечение пустое?",
_ => "нулевые или несовместимые размеры?",
};
}
@@ -14,6 +14,7 @@ public enum SketchEntityKind
Spline,
Point,
Axis,
Text,
}
/// <summary>
@@ -41,4 +42,22 @@ public sealed record SketchEntity
public bool Closed { get; init; }
public bool CounterClockwise { get; init; } = true;
public bool Inscribed { get; init; } = true;
// ---- text ---------------------------------------------------------------
/// <summary>Содержимое надписи (Kind=Text). Строится шрифтом <see cref="FontName"/>
/// и сразу переводится в кривые — иначе текст в эскизе не участвует в выдавливании.</summary>
public string? Text { get; init; }
/// <summary>Имя установленного в системе шрифта. Пусто — текущий шрифт документа (GOST type A).</summary>
public string? FontName { get; init; }
/// <summary>Высота шрифта, мм (Kind=Text).</summary>
public double Height { get; init; }
/// <summary>Коэффициент сужения: 1 — нормальная ширина, &lt;1 — уже, &gt;1 — шире.</summary>
public double WidthFactor { get; init; } = 1.0;
public bool Bold { get; init; }
public bool Italic { get; init; }
}
@@ -10,12 +10,13 @@ namespace Kompas.Mcp.Core.Modeling;
public sealed class SketchEntityInput
{
[JsonPropertyName("type")]
[Description("Тип примитива: line | circle | rectangle | arc | arc3points | ellipse | polyline | polygon | spline | point | axis")]
[Description("Тип примитива: line | circle | rectangle | arc | arc3points | ellipse | polyline | polygon | spline | point | axis | text")]
public string Type { get; set; } = "";
[JsonPropertyName("points")]
[Description("Точки в плоскости эскиза (мм). line/axis — 2 точки; rectangle — 2 противоположных угла; " +
"arc3points — 3 (начало, промежуточная на дуге, конец); polyline/spline — N точек; point — 1")]
"arc3points — 3 (начало, промежуточная на дуге, конец); polyline/spline — N точек; " +
"point — 1; text — 1 (левый край базовой линии надписи)")]
public SketchPoint[]? Points { get; set; }
[JsonPropertyName("centerX")]
@@ -47,9 +48,35 @@ public sealed class SketchEntityInput
public double? SemiMinor { get; set; }
[JsonPropertyName("angle")]
[Description("Угол поворота, градусы: наклон большой оси (ellipse) или поворот первой вершины (polygon). По умолчанию 0")]
[Description("Угол поворота, градусы: наклон большой оси (ellipse), поворот первой вершины (polygon) " +
"или наклон строки (text). По умолчанию 0")]
public double? Angle { get; set; }
[JsonPropertyName("text")]
[Description("Содержимое надписи — для text. Сразу переводится в кривые, поэтому надпись можно выдавливать")]
public string? Text { get; set; }
[JsonPropertyName("fontName")]
[Description("Имя шрифта, установленного в системе (например «GOST type A», «Arial», «Lobster») — для text. " +
"По умолчанию текущий шрифт документа")]
public string? FontName { get; set; }
[JsonPropertyName("height")]
[Description("Высота шрифта, мм — для text (примерно высота прописной с выносным элементом)")]
public double? Height { get; set; }
[JsonPropertyName("widthFactor")]
[Description("Коэффициент сужения шрифта — для text: 1 обычный, <1 уже, >1 шире. По умолчанию 1")]
public double? WidthFactor { get; set; }
[JsonPropertyName("bold")]
[Description("Полужирное начертание — для text. По умолчанию false")]
public bool? Bold { get; set; }
[JsonPropertyName("italic")]
[Description("Наклонное начертание — для text. По умолчанию false")]
public bool? Italic { get; set; }
[JsonPropertyName("vertexCount")]
[Description("Число вершин (>=3) — для polygon")]
public int? VertexCount { get; set; }
@@ -74,7 +101,7 @@ public sealed class SketchEntityInput
/// </summary>
public static class SketchEntityInputs
{
private const string KnownTypes = "line | circle | rectangle | arc | arc3points | ellipse | polyline | polygon | spline | point | axis";
private const string KnownTypes = "line | circle | rectangle | arc | arc3points | ellipse | polyline | polygon | spline | point | axis | text";
public static IReadOnlyList<SketchEntity> Parse(IReadOnlyList<SketchEntityInput>? entities)
{
@@ -140,6 +167,12 @@ public static class SketchEntityInputs
Require(e.Radius, "radius", kind);
Require(e.VertexCount, "vertexCount", kind);
break;
case SketchEntityKind.Text:
RequirePoints(points, 1, kind, "точку вставки (левый край базовой линии)");
if (string.IsNullOrEmpty(e.Text))
throw new ArgumentException("type=text требует параметр text (непустую строку).");
Require(e.Height, "height", kind);
break;
}
return new SketchEntity
@@ -158,6 +191,12 @@ public static class SketchEntityInputs
Closed = e.Closed ?? false,
CounterClockwise = e.CounterClockwise ?? true,
Inscribed = e.Inscribed ?? true,
Text = e.Text,
FontName = e.FontName,
Height = e.Height ?? 0,
WidthFactor = e.WidthFactor ?? 1.0,
Bold = e.Bold ?? false,
Italic = e.Italic ?? false,
};
}
@@ -174,6 +213,7 @@ public static class SketchEntityInputs
"spline" => SketchEntityKind.Spline,
"point" => SketchEntityKind.Point,
"axis" => SketchEntityKind.Axis,
"text" => SketchEntityKind.Text,
_ => throw new ArgumentException($"неизвестный тип примитива '{type}'. Допустимо: {KnownTypes}."),
};
+26
View File
@@ -0,0 +1,26 @@
namespace Kompas.Mcp.Core.Modeling;
/// <summary>Куда откладывается толщина тонкой стенки от контура эскиза.</summary>
public enum ThinSide
{
/// <summary>Наружу от контура (контур — внутренняя граница стенки).</summary>
Outward,
/// <summary>Внутрь контура (контур — наружная граница стенки).</summary>
Inward,
/// <summary>Симметрично в обе стороны (контур — средняя линия стенки).</summary>
Both,
}
public static class ThinSides
{
public static ThinSide Parse(string? value) => (value ?? "").Trim().ToLowerInvariant() switch
{
"" or "outward" or "out" => ThinSide.Outward,
"inward" or "in" => ThinSide.Inward,
"both" or "middle" => ThinSide.Both,
_ => throw new ArgumentException(
$"Неизвестная сторона стенки '{value}'. Допустимо: outward | inward | both.", nameof(value)),
};
}
+49 -4
View File
@@ -13,27 +13,72 @@ public sealed class FeatureTools(
KompasSession session,
PartModeler modeler,
HoleService holes,
PrimitiveService primitives,
ModelInspectionService inspection,
AutoValidation autoValidation)
{
[McpServerTool(Name = "primitive")]
[Description("Построить элементарное тело БЕЗ эскиза, по размерам: kind=block (length×width×height " +
"по осям X/Y/Z, точка x,y,z — угол) | cylinder (diameter, height) | sphere (diameter) | " +
"cone (diameter основания, height); у тел вращения x,y,z — центр основания. " +
"result задаёт, что делать с уже построенным: new (отдельное тело) | union (приплюсовать) | " +
"subtract (вычесть — карман или паз без эскиза и выреза) | intersect. Для призматических " +
"форм это короче связки sketch_create+extrude; сложный контур (текст, кривые) по-прежнему " +
"делается эскизом.")]
public async Task<string> Primitive(
[Description("Тип тела: block | cylinder | sphere | cone")] string kind,
[Description("X точки привязки, мм")] double x = 0,
[Description("Y точки привязки, мм")] double y = 0,
[Description("Z точки привязки, мм")] double z = 0,
[Description("Длина по X, мм — для block")] double? length = null,
[Description("Ширина по Y, мм — для block")] double? width = null,
[Description("Высота по Z, мм — для block | cylinder | cone")] double? height = null,
[Description("Диаметр, мм — для cylinder | sphere | cone (основания)")] double? diameter = null,
[Description("Что сделать с существующим телом: new | union | subtract | intersect")] string result = "new",
[Description("Прямое направление высоты (иначе вниз от точки привязки)")] bool forward = true)
{
await session.ConnectAsync();
var parsedKind = Primitives.ParseKind(kind);
var parsedResult = Primitives.ParseResult(result);
await primitives.PrimitiveAsync(parsedKind, x, y, z, length, width, height, diameter, parsedResult, forward);
var what = parsedKind.ToString().ToLowerInvariant();
var how = parsedResult switch
{
PrimitiveResult.Union => "объединён с телом",
PrimitiveResult.Subtract => "вычтен из тела",
PrimitiveResult.Intersect => "оставлено пересечение",
_ => "создан отдельным телом",
};
return await autoValidation.AnnotateAsync($"Примитив {what} в ({x}, {y}, {z}) — {how}.");
}
[McpServerTool(Name = "extrude")]
[Description("Выдавить эскиз: mode=boss — добавить материал, mode=cut — вырезать. depth — глубина в мм. " +
"Для сквозного выреза передайте throughAll=true (depth не нужен; для boss сквозной режим " +
"неприменим). forward — прямое направление; если операция ушла «не туда», повторите с " +
"forward=false. Возвращает id операции.")]
"forward=false. thinThickness>0 превращает контур в стенку такой толщины (тонкостенное тело); " +
"thinSide=outward наращивает её наружу — так из контура получается ободок/эквидистанта вокруг " +
"него. Возвращает id операции.")]
public async Task<string> Extrude(
[Description("Id эскиза (должен быть закрыт или закроется автоматически)")] int sketchId,
[Description("Режим: boss (добавить материал) | cut (вырезать)")] string mode,
[Description("Глубина, мм (не нужна при throughAll)")] double depth = 0,
[Description("Вырезать насквозь через всю деталь (только для cut)")] bool throughAll = false,
[Description("Прямое направление")] bool forward = true)
[Description("Прямое направление")] bool forward = true,
[Description("Толщина тонкой стенки, мм; 0 — сплошное сечение по контуру")] double thinThickness = 0,
[Description("Сторона стенки от контура: outward | inward | both")] string thinSide = "outward")
{
await session.ConnectAsync();
var cut = ParseCutMode(mode);
var id = await modeler.ExtrudeAsync(sketchId, depth, cut: cut, forward: forward, throughAll: throughAll);
var side = ThinSides.Parse(thinSide);
var id = await modeler.ExtrudeAsync(sketchId, depth, cut: cut, forward: forward, throughAll: throughAll,
thinThickness: thinThickness, thinSide: side);
var what = cut ? "Вырез" : "Выдавливание";
var how = throughAll ? "насквозь" : $"на {depth} мм";
return await autoValidation.AnnotateAsync($"{what} выдавливанием {how} создан(о), id={id}.");
var wall = thinThickness > 0 ? $", стенка {thinThickness} мм ({side.ToString().ToLowerInvariant()})" : "";
return await autoValidation.AnnotateAsync($"{what} выдавливанием {how}{wall} создан(о), id={id}.");
}
[McpServerTool(Name = "revolve")]
+11 -4
View File
@@ -69,9 +69,9 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
return $"Эскиз id={id} создан на {on} и открыт. Добавьте геометрию через sketch_add.";
// Пакет строится за один заход на COM-поток; autoClose закрывает эскиз там же.
await modeler.AddEntitiesAsync(id, parsed, autoClose);
var notes = await modeler.AddEntitiesAsync(id, parsed, autoClose);
var state = autoClose ? "закрыт — можно строить операцию" : "остался открытым";
return $"Эскиз id={id} создан на {on}, добавлено примитивов: {parsed.Count}, {state}.";
return $"Эскиз id={id} создан на {on}, добавлено примитивов: {parsed.Count}, {state}.{Notes(notes)}";
}
[McpServerTool(Name = "sketch_add")]
@@ -90,9 +90,16 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
if (parsed.Count == 0)
throw new ArgumentException("Список entities пуст — нечего добавлять.");
await modeler.AddEntitiesAsync(sketchId, parsed, autoClose);
var notes = await modeler.AddEntitiesAsync(sketchId, parsed, autoClose);
var state = autoClose ? " Эскиз закрыт." : "";
return $"В эскиз id={sketchId} добавлено примитивов: {parsed.Count}.{state}";
return $"В эскиз id={sketchId} добавлено примитивов: {parsed.Count}.{state}{Notes(notes)}";
}
/// <summary>Заметки по построенным примитивам (сейчас — фактическая длина надписей).</summary>
private static string Notes(IReadOnlyList<string> notes)
{
var meaningful = notes.Where(n => !string.IsNullOrEmpty(n)).ToList();
return meaningful.Count == 0 ? "" : " " + string.Join("; ", meaningful) + ".";
}
[McpServerTool(Name = "sketch_close")]
@@ -54,7 +54,7 @@ public sealed class MergedToolPathsTests : IntegrationTestBase
Rect(0, 0, 40, 20),
Circle(5, 5, 2), Circle(35, 5, 2), Circle(5, 15, 2), Circle(35, 15, 2),
], autoClose: true);
Assert.Equal(5, added);
Assert.Equal(5, added.Count);
await _modeler.ExtrudeAsync(s, depth: 10);
await _modeler.RebuildAsync();
@@ -0,0 +1,116 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Интеграция: элементарные тела (API7 <c>IElementaryBodies</c>) — второй путь формообразования
/// рядом с «эскиз → выдавливание», в том числе вычитание примитива вместо выреза по эскизу.
/// </summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class PrimitiveTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PrimitiveService _primitives;
private readonly PartModeler _modeler;
private readonly QueryService _query;
public PrimitiveTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_primitives = new PrimitiveService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_query = new QueryService(fx.Session, fx.Dispatcher);
}
[Fact]
public async Task Block_is_built_by_sizes_from_the_anchor_corner()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
await _primitives.PrimitiveAsync(PrimitiveKind.Block, x: 4, y: 0, z: 0,
length: 82, width: 10.5, height: 3);
await _modeler.RebuildAsync();
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.MinX, 3.99, 4.01); // точка привязки — угол, а не центр
Assert.InRange(box.SizeX, 81.9, 82.1);
Assert.InRange(box.SizeY, 10.4, 10.6);
Assert.InRange(box.SizeZ, 2.99, 3.01);
var v = (await _query.GetPartInfoAsync()).Volume; // 82·10.5·3 = 2583
Assert.InRange(v, 2583 * 0.99, 2583 * 1.01);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Subtracted_block_hollows_a_pocket_without_a_sketch()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
await _primitives.PrimitiveAsync(PrimitiveKind.Block, 0, 0, 0, length: 40, width: 20, height: 10);
// Карман 30×10 глубиной 4 — вычитаем блок, приподняв его до дна кармана.
await _primitives.PrimitiveAsync(PrimitiveKind.Block, 5, 5, 6, length: 30, width: 10, height: 10,
result: PrimitiveResult.Subtract);
await _modeler.RebuildAsync();
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.SizeZ, 9.9, 10.1); // габарит не изменился — вырезано внутри
var v = (await _query.GetPartInfoAsync()).Volume; // 40·20·10 30·10·4 = 6800
Assert.InRange(v, 6800 * 0.99, 6800 * 1.01);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Cylinder_can_be_added_to_an_existing_body()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
await _primitives.PrimitiveAsync(PrimitiveKind.Block, 0, 0, 0, length: 20, width: 20, height: 5);
await _primitives.PrimitiveAsync(PrimitiveKind.Cylinder, 10, 10, 5, diameter: 8, height: 10,
result: PrimitiveResult.Union);
await _modeler.RebuildAsync();
var bodies = await _query.ListFacesAsync();
Assert.NotEmpty(bodies);
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.SizeZ, 14.9, 15.1); // 5 плиты + 10 цилиндра
var v = (await _query.GetPartInfoAsync()).Volume; // 20·20·5 + π·4²·10 ≈ 2502.7
var expected = 20 * 20 * 5 + Math.PI * 16 * 10;
Assert.InRange(v, expected * 0.98, expected * 1.02);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Subtracting_where_nothing_intersects_fails_with_a_hint()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
await _primitives.PrimitiveAsync(PrimitiveKind.Block, 0, 0, 0, length: 10, width: 10, height: 10);
// Update() при вычитании мимо тела возвращает TRUE — ловим по неизменившемуся объёму.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
_primitives.PrimitiveAsync(PrimitiveKind.Sphere, 100, 100, 100, diameter: 5,
result: PrimitiveResult.Subtract));
Assert.Contains("не удалило материал", ex.Message, StringComparison.Ordinal);
// Неудачная операция не должна оставаться в дереве: объём прежний, тело одно.
var v = (await _query.GetPartInfoAsync()).Volume;
Assert.InRange(v, 1000 * 0.99, 1000 * 1.01);
}
finally { await _docs.CloseAsync(save: false); }
}
}
@@ -0,0 +1,125 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Интеграция: надпись в эскизе. Текст сам по себе — оформление, выдавить его нельзя;
/// примитив type=text переводит его в кривые (ksConvertTextToCurve), и контур глифов
/// становится обычным сечением операции.
/// </summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class SketchTextTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PartModeler _modeler;
private readonly QueryService _query;
public SketchTextTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_query = new QueryService(fx.Session, fx.Dispatcher);
}
private static SketchEntity Text(string text, double x, double y, double height, string? font = null) => new()
{
Kind = SketchEntityKind.Text,
Points = [(x, y)],
Text = text,
Height = height,
FontName = font,
};
[Fact]
public async Task Text_becomes_curves_and_extrudes()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
var notes = await _modeler.AddEntitiesAsync(s, [Text("HI", 0, 0, 10, "Arial")], autoClose: true);
await _modeler.ExtrudeAsync(s, depth: 2);
await _modeler.RebuildAsync();
// Длина строки нужна агенту для выравнивания: до выдавливания её узнать неоткуда.
Assert.Contains("длина", notes[0], StringComparison.Ordinal);
var info = await _query.GetPartInfoAsync();
Assert.True(info.Volume > 0, "надпись не дала объёма — текст не переведён в кривые");
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.SizeZ, 1.99, 2.01);
Assert.InRange(box.SizeY, 6, 11); // высота прописных ≈ height
Assert.True(box.SizeX > box.SizeY); // «HI» шире, чем выше
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Unknown_font_is_reported_by_name()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
var bad = Text("HI", 0, 0, 10, "No Such Font 12345");
// КОМПАС подставляет замену вместо отсутствующего шрифта, поэтому надпись
// всё равно строится — важно, что она не рушит пакет и даёт геометрию.
var notes = await _modeler.AddEntitiesAsync(s, [bad], autoClose: true);
Assert.False(string.IsNullOrEmpty(notes[0]));
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Thin_wall_outward_grows_the_contour()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Квадрат 20×20 стенкой 2 мм наружу: габарит вырастает на 2·2 мм по каждой оси,
// а объём равен объёму рамки, а не сплошной плиты.
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddRectangleAsync(s, 0, 0, 20, 20);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 5, thinThickness: 2, thinSide: ThinSide.Outward);
await _modeler.RebuildAsync();
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.SizeX, 23.9, 24.1);
Assert.InRange(box.SizeY, 23.9, 24.1);
var v = (await _query.GetPartInfoAsync()).Volume; // (24² 20²)·5 = 880
Assert.InRange(v, 880 * 0.98, 880 * 1.02);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Thin_wall_inward_keeps_the_contour_and_hollows_it()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Стенка внутрь контура: габарит остаётся 20×20, материал — только рамка по периметру.
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddRectangleAsync(s, 0, 0, 20, 20);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 5, thinThickness: 2, thinSide: ThinSide.Inward);
await _modeler.RebuildAsync();
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.SizeX, 19.9, 20.1);
Assert.InRange(box.SizeY, 19.9, 20.1);
// (20² 16²)·5 = 720; сплошная плита дала бы 2000 — так ловится «стенка не применилась».
var v = (await _query.GetPartInfoAsync()).Volume;
Assert.InRange(v, 720 * 0.98, 720 * 1.02);
}
finally { await _docs.CloseAsync(save: false); }
}
}
+77
View File
@@ -0,0 +1,77 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Разбор и валидация примитивов. У слитого инструмента схема не запрещает лишние поля,
/// поэтому недостающий размер обязана назвать валидация — вместе с типом тела.
/// </summary>
[Trait("Category", "Unit")]
public sealed class PrimitivesTests
{
[Theory]
[InlineData("block", PrimitiveKind.Block)]
[InlineData(" BOX ", PrimitiveKind.Block)]
[InlineData("cylinder", PrimitiveKind.Cylinder)]
[InlineData("sphere", PrimitiveKind.Sphere)]
[InlineData("cone", PrimitiveKind.Cone)]
public void Kind_names_and_aliases_are_recognized(string value, PrimitiveKind expected)
=> Assert.Equal(expected, Primitives.ParseKind(value));
[Theory]
[InlineData("", PrimitiveResult.NewBody)]
[InlineData("new", PrimitiveResult.NewBody)]
[InlineData("union", PrimitiveResult.Union)]
[InlineData("subtract", PrimitiveResult.Subtract)]
[InlineData("cut", PrimitiveResult.Subtract)]
[InlineData("intersect", PrimitiveResult.Intersect)]
public void Result_names_and_aliases_are_recognized(string value, PrimitiveResult expected)
=> Assert.Equal(expected, Primitives.ParseResult(value));
[Fact]
public void Unknown_kind_lists_the_allowed_ones()
{
var ex = Assert.Throws<ArgumentException>(() => Primitives.ParseKind("torus"));
Assert.Contains("torus", ex.Message, StringComparison.Ordinal);
Assert.Contains("cylinder", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Block_names_the_missing_dimension()
{
var ex = Assert.Throws<ArgumentException>(
() => Primitives.Validate(PrimitiveKind.Block, length: 10, width: null, height: 5, diameter: null));
Assert.Contains("block", ex.Message, StringComparison.Ordinal);
Assert.Contains("width", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Cylinder_needs_diameter_and_height_not_length()
{
var ex = Assert.Throws<ArgumentException>(
() => Primitives.Validate(PrimitiveKind.Cylinder, length: 10, width: 10, height: 5, diameter: null));
Assert.Contains("diameter", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Sphere_needs_only_the_diameter()
{
Primitives.Validate(PrimitiveKind.Sphere, length: null, width: null, height: null, diameter: 12);
}
[Fact]
public void Non_positive_size_is_rejected_with_the_value()
{
var ex = Assert.Throws<ArgumentException>(
() => Primitives.Validate(PrimitiveKind.Block, length: 10, width: 0, height: 5, diameter: null));
Assert.Contains("width", ex.Message, StringComparison.Ordinal);
Assert.Contains("> 0", ex.Message, StringComparison.Ordinal);
}
// Маппинг в COM-перечисления (ksObj3dTypeEnum / ksOperationResultEnum) проверяется
// интеграционно — сборки КОМПАС в тестовом проекте недоступны без установленного CAD.
}
@@ -102,6 +102,42 @@ public sealed class SketchEntityInputsTests
Assert.Equal(0, parsed.Angle);
}
[Fact]
public void Text_requires_insertion_point_string_and_height()
{
var noPoint = new SketchEntityInput { Type = "text", Text = "OldMan", Height = 10 };
Assert.Contains("points", Assert.Throws<ArgumentException>(
() => SketchEntityInputs.Parse([noPoint])).Message, StringComparison.Ordinal);
var noText = new SketchEntityInput { Type = "text", Points = [new SketchPoint(0, 0)], Height = 10 };
Assert.Contains("text", Assert.Throws<ArgumentException>(
() => SketchEntityInputs.Parse([noText])).Message, StringComparison.Ordinal);
var noHeight = new SketchEntityInput { Type = "text", Points = [new SketchPoint(0, 0)], Text = "OldMan" };
Assert.Contains("height", Assert.Throws<ArgumentException>(
() => SketchEntityInputs.Parse([noHeight])).Message, StringComparison.Ordinal);
}
[Fact]
public void Text_defaults_keep_normal_width_and_upright_face()
{
var e = new SketchEntityInput
{
Type = "text", Points = [new SketchPoint(4, 12)], Text = "EDITION", Height = 6, FontName = "Zilla Slab",
};
var parsed = SketchEntityInputs.Parse([e])[0];
Assert.Equal(SketchEntityKind.Text, parsed.Kind);
Assert.Equal("EDITION", parsed.Text);
Assert.Equal("Zilla Slab", parsed.FontName);
Assert.Equal(6, parsed.Height);
Assert.Equal(1.0, parsed.WidthFactor);
Assert.False(parsed.Bold);
Assert.False(parsed.Italic);
Assert.Equal((4.0, 12.0), parsed.Points[0]);
}
[Fact]
public void Points_keep_their_order()
{
+29
View File
@@ -0,0 +1,29 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>Разбор стороны тонкой стенки: значение приходит строкой из схемы инструмента.</summary>
[Trait("Category", "Unit")]
public sealed class ThinSidesTests
{
[Theory]
[InlineData("outward", ThinSide.Outward)]
[InlineData(" OUT ", ThinSide.Outward)]
[InlineData("", ThinSide.Outward)]
[InlineData(null, ThinSide.Outward)]
[InlineData("inward", ThinSide.Inward)]
[InlineData("in", ThinSide.Inward)]
[InlineData("both", ThinSide.Both)]
[InlineData("middle", ThinSide.Both)]
public void Known_values_and_aliases_are_recognized(string? value, ThinSide expected)
=> Assert.Equal(expected, ThinSides.Parse(value));
[Fact]
public void Unknown_value_lists_the_allowed_ones()
{
var ex = Assert.Throws<ArgumentException>(() => ThinSides.Parse("sideways"));
Assert.Contains("sideways", ex.Message, StringComparison.Ordinal);
Assert.Contains("outward", ex.Message, StringComparison.Ordinal);
}
}