Формообразование: надписи в эскизе, тонкая стенка, тела по размерам
Три способа получить форму там, где раньше был только замкнутый контур из отрезков и дуг. 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:
@@ -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 — нормальная ширина, <1 — уже, >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}."),
|
||||
};
|
||||
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user