Дерево построения: имя у каждой операции, скругление пакетом по точкам

Имя (name) прокидывается до создания объекта: API5 — ksEntity.name перед Create(),
API7-пути (примитив, отверстие, булева, рез, перенос грани) — IModelObject.Name перед
Update(). Читается назад через ksFeature.name, который только на чтение. Без имени
дерево состоит из «Эскиз:1» и «Элемент выдавливания:3».

fillet_edge/chamfer_edge принимают список точек: на теле с сотнями рёбер list_edges
неподъёмен, а выбор по точке брал одно ребро — четыре угла давали четыре операции.
Ненайденные рёбра перечисляются ВСЕ сразу, модель при этом не мутируется.
This commit is contained in:
2026-07-31 16:35:44 +03:00
parent 814c9cf7b1
commit 461e731857
15 changed files with 609 additions and 178 deletions
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Threading;
using KompasAPI7;
@@ -25,10 +25,10 @@ public sealed class FaceEditService
/// на <paramref name="distance"/> мм вдоль её нормали. Направление подбирается автоматически
/// (наружу — добавление материала); если КОМПАС отвергает направление, пробуется обратное.
/// </summary>
public Task MoveFaceAsync(double x, double y, double z, double distance, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => MoveFaceCore(x, y, z, distance), ct);
public Task MoveFaceAsync(double x, double y, double z, double distance, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => MoveFaceCore(x, y, z, distance, name), ct);
private void MoveFaceCore(double x, double y, double z, double distance)
private void MoveFaceCore(double x, double y, double z, double distance, string? name)
{
if (distance == 0) throw new ArgumentException("Смещение не может быть нулевым.", nameof(distance));
@@ -49,6 +49,8 @@ public sealed class FaceEditService
if (!fm.SetFaces(new object[] { face }, true))
throw new InvalidOperationException("FaceMover.SetFaces отверг грань.");
fm.Offset = Math.Abs(distance);
// Имя ставим до Update(): иначе в дереве останется системное.
if (Modeling.FeatureName.Normalize(name) is { } title && fm is IModelObject named) named.Name = title;
// Направление: пробуем одно, при отказе — обратное (нормаль грани может смотреть в любую сторону).
fm.Direction = distance > 0;
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Threading;
using Kompas6Constants;
@@ -29,7 +29,7 @@ public sealed class SolidEditService
/// Возвращает габариты получившихся тел (с индексами) — по ним выбирают тело для <see cref="MoveBodyAsync"/>.
/// </summary>
public Task<IReadOnlyList<BodyBox>> SplitByPlaneAsync(
BasePlane basePlane, double offset, bool direction = true, CancellationToken ct = default)
BasePlane basePlane, double offset, bool direction = true, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync<IReadOnlyList<BodyBox>>(() =>
{
var (doc3d, top) = ActivePart();
@@ -45,6 +45,9 @@ public sealed class SolidEditService
var mc = ModelContainer(top);
var ss = mc.SplitSolids.Add();
ss.CutObjects = plane;
// Имя ставим до Update(): иначе в дереве останется системное.
if (Modeling.FeatureName.Normalize(name) is { } title && ss is IModelObject named)
named.Name = title;
if (!ss.Update())
throw new InvalidOperationException("SplitSolid.Update вернул FALSE (рассечение не удалось).");
doc3d.RebuildDocument();
@@ -52,7 +55,7 @@ public sealed class SolidEditService
}, ct);
/// <summary>Переместить тело (по индексу из <see cref="SplitByPlaneAsync"/>) на вектор (dx, dy, dz) мм.</summary>
public Task MoveBodyAsync(int bodyIndex, double dx = 0, double dy = 0, double dz = 0, CancellationToken ct = default)
public Task MoveBodyAsync(int bodyIndex, double dx = 0, double dy = 0, double dz = 0, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var (doc3d, top) = ActivePart();
@@ -67,13 +70,16 @@ public sealed class SolidEditService
var pos = br.Position;
pos.SetStartingOrientation();
pos.X = dx; pos.Y = dy; pos.Z = dz; // смещение начала ЛСК тела = перенос тела
// Имя ставим до Update(): иначе в дереве останется системное.
if (Modeling.FeatureName.Normalize(name) is { } title && br is IModelObject named)
named.Name = title;
if (!br.Update())
throw new InvalidOperationException("BodyReposition.Update вернул FALSE.");
doc3d.RebuildDocument();
}, ct);
/// <summary>Объединить все тела активной детали в одно (булева операция «объединение»).</summary>
public Task UnionAllAsync(CancellationToken ct = default)
public Task UnionAllAsync(string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var (doc3d, top) = ActivePart();
@@ -85,6 +91,9 @@ public sealed class SolidEditService
bln.BaseObject = (IKompasAPIObject)bodies[0];
bln.ModifyObjects = bodies.Skip(1).Cast<object>().ToArray();
bln.BooleanType = ksBooleanType.ksUnion;
// Имя ставим до Update(): иначе в дереве останется системное.
if (Modeling.FeatureName.Normalize(name) is { } title && bln is IModelObject named)
named.Name = title;
if (!bln.Update())
throw new InvalidOperationException("Boolean union.Update вернул FALSE.");
doc3d.RebuildDocument();
@@ -0,0 +1,41 @@
namespace Kompas.Mcp.Core.Modeling;
/// <summary>
/// Имя операции (эскиза, формообразующей, скругления…) в дереве построения.
/// <para>КОМПАС по умолчанию именует объекты по типу и номеру («Эскиз:4», «Операция выдавливания:7»),
/// поэтому дерево из полутора десятков операций ничего не говорит ни человеку, ни агенту,
/// вернувшемуся к модели позже. Имя задаётся при создании; здесь — только нормализация и проверка,
/// чтобы она покрывалась тестами без COM.</para>
/// </summary>
public static class FeatureName
{
/// <summary>Предел длины. В справке SDK не документирован — берём заведомо безопасный.</summary>
public const int MaxLength = 100;
/// <summary>
/// Привести имя к виду, пригодному для записи в <c>ksEntity.name</c>: обрезать пробелы по краям,
/// пустое считать «имя не задано» (<c>null</c> — КОМПАС оставит собственное).
/// </summary>
/// <exception cref="ArgumentException">Имя длиннее <see cref="MaxLength"/> или содержит перевод
/// строки/управляющие символы — в дереве такое имя отображается искажённо.</exception>
public static string? Normalize(string? name, string parameterName = "name")
{
if (name is null) return null;
var trimmed = name.Trim();
if (trimmed.Length == 0) return null;
if (trimmed.Length > MaxLength)
throw new ArgumentException(
$"Имя операции длиннее {MaxLength} символов ({trimmed.Length}). " +
"Дайте короткое имя — оно должно читаться в дереве построения.", parameterName);
foreach (var c in trimmed)
{
if (!char.IsControl(c)) continue;
throw new ArgumentException(
"Имя операции не должно содержать перевод строки и управляющие символы.", parameterName);
}
return trimmed;
}
}
+13 -10
View File
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Threading;
using Kompas6API5;
using Kompas6Constants3D;
@@ -27,13 +27,14 @@ public sealed class HoleService
/// (<paramref name="x"/>,<paramref name="y"/>,<paramref name="z"/>), с центром в этой точке.
/// diameter — диаметр (мм). throughAll — сквозное (depth игнорируется), иначе на глубину depth (мм).
/// </summary>
public Task HoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => HoleCore(x, y, z, diameter, depth, throughAll, ksHoleTypeEnum.ksHTBase, null), ct);
public Task HoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll,
string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => HoleCore(x, y, z, diameter, depth, throughAll, ksHoleTypeEnum.ksHTBase, null, name), ct);
/// <summary>Отверстие с цековкой (цилиндрическим уширением сверху, под винт с цилиндрической
/// головкой): основное отверстие diameter + цековка spotDiameter×spotDepth.</summary>
public Task CounterboreHoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll,
double spotDiameter, double spotDepth, CancellationToken ct = default)
double spotDiameter, double spotDepth, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!double.IsFinite(spotDiameter) || spotDiameter <= diameter)
@@ -48,13 +49,13 @@ public sealed class HoleService
?? throw new InvalidOperationException("HoleParameters не приводится к ISpotfacingHoleParameters.");
p.SpotfacingDiameter = spotDiameter;
p.SpotfacingDepth = spotDepth;
});
}, name);
}, ct);
/// <summary>Отверстие с зенковкой (коническим уширением сверху, под винт с потайной головкой):
/// основное отверстие diameter + зенковка sinkDiameter под углом sinkAngle (градусы, конус).</summary>
public Task CountersinkHoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll,
double sinkDiameter, double sinkAngle, CancellationToken ct = default)
double sinkDiameter, double sinkAngle, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!double.IsFinite(sinkDiameter) || sinkDiameter <= diameter)
@@ -68,13 +69,13 @@ public sealed class HoleService
p.CountersinkType = ksCountersinkTypeEnum.ksCTDiameterAngle; // задаём диаметр + угол
p.CountersinkDiameter = sinkDiameter;
p.CountersinkAngle = sinkAngle;
});
}, name);
}, ct);
/// <summary>Коническое отверстие (ksHTConic): конусность задаётся углом conicAngle (градусы).
/// diameter — диаметр у дна, конус расширяется к поверхности.</summary>
public Task ConicHoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll,
double conicAngle, CancellationToken ct = default)
double conicAngle, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!double.IsFinite(conicAngle) || conicAngle <= 0 || conicAngle >= 180)
@@ -85,11 +86,11 @@ public sealed class HoleService
?? throw new InvalidOperationException("HoleParameters не приводится к IConicHoleParameters.");
p.ConicType = ksConicTypeEnum.ksCNAngle;
p.ConicAngle = conicAngle;
});
}, name);
}, ct);
private void HoleCore(double x, double y, double z, double diameter, double depth, bool throughAll,
ksHoleTypeEnum holeType, Action<IHole3D>? configure)
ksHoleTypeEnum holeType, Action<IHole3D>? configure, string? name)
{
if (!double.IsFinite(x) || !double.IsFinite(y) || !double.IsFinite(z))
throw new ArgumentException("Координаты точки отверстия должны быть конечными числами.");
@@ -134,6 +135,8 @@ public sealed class HoleService
hole.EndFaceType = ksEndFaceTypeEnum.ksEFFlat;
hole.Axis = false;
hole.ShowThread = false;
// Имя ставим до Update(): иначе в дереве останется «Отверстие:1».
if (FeatureName.Normalize(name) is { } title && hole is IModelObject named) named.Name = title;
configure?.Invoke(hole); // параметры цековки/зенковки (после установки HoleType)
var disp = (IHoleDisposal)hole;
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas6API5;
using Kompas6Constants3D;
@@ -17,10 +17,12 @@ public sealed partial class PartModeler
/// <param name="thinThickness">Толщина тонкой стенки, мм: контур эскиза трактуется не как граница
/// сплошного сечения, а как средняя/крайняя линия стенки такой толщины. 0 — сплошное тело.</param>
/// <param name="thinSide">Куда откладывать толщину от контура: Outward | Inward | Both.</param>
/// <param name="name">Имя операции в дереве построения («Рельеф букв OldMan»); пусто — имя от КОМПАС.</param>
public Task<int> ExtrudeAsync(
int sketchId, double depth, bool cut = false, bool forward = true,
double draftAngle = 0, bool throughAll = false,
double thinThickness = 0, ThinSide thinSide = ThinSide.Outward, CancellationToken ct = default)
double thinThickness = 0, ThinSide thinSide = ThinSide.Outward,
string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!throughAll && depth <= 0)
@@ -32,8 +34,7 @@ public sealed partial class PartModeler
var part = GetTopPart();
var type = cut ? Obj3dType.o3d_cutExtrusion : Obj3dType.o3d_bossExtrusion;
var entity = part.NewEntity((short)type) as ksEntity
?? throw new InvalidOperationException("NewEntity операции выдавливания вернул null.");
var entity = NewEntity(part, type, name);
// Конкретные типы определений (без dynamic): ошибки связывания — на этапе компиляции.
if (cut)
@@ -76,9 +77,7 @@ public sealed partial class PartModeler
"форму через primitive(result=union|subtract)."
: " Эскиз пуст, контур не замкнут или операция не пересекает тело?"));
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Разложить толщину стенки в параметры ksBoss/CutExtrusionDefinition.SetThinParam.</summary>
@@ -102,7 +101,7 @@ public sealed partial class PartModeler
/// </summary>
public Task<int> RevolveAsync(
int sketchId, double angle = 360, bool cut = false, bool forward = true,
CancellationToken ct = default)
string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (angle is <= 0 or > 360)
@@ -112,8 +111,7 @@ public sealed partial class PartModeler
var part = GetTopPart();
var type = cut ? Obj3dType.o3d_cutRotated : Obj3dType.o3d_bossRotated;
var entity = part.NewEntity((short)type) as ksEntity
?? throw new InvalidOperationException("NewEntity операции вращения вернул null.");
var entity = NewEntity(part, type, name);
if (cut)
{
@@ -138,35 +136,39 @@ public sealed partial class PartModeler
if (!entity.Create())
throw new InvalidOperationException("Create() операции вращения вернул FALSE.");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>
/// Скруглить ребро детали, найденное по точке (мировые координаты, мм), радиусом <paramref name="radius"/>.
/// Возвращает id операции.
/// </summary>
public Task<int> FilletEdgeAsync(double x, double y, double z, double radius, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
return CreateFillet(part, SelectEdgeByPoint(part, x, y, z), radius);
}, ct);
public Task<int> FilletEdgeAsync(double x, double y, double z, double radius, string? name = null, CancellationToken ct = default)
=> FilletEdgesByPointsAsync([new Point3d(x, y, z)], radius, name, ct);
/// <summary>Скруглить ребро детали по индексу из list_edges радиусом <paramref name="radius"/>.</summary>
public Task<int> FilletEdgeIndexAsync(int edgeIndex, double radius, CancellationToken ct = default)
=> FilletEdgesAsync([edgeIndex], radius, ct);
public Task<int> FilletEdgeIndexAsync(int edgeIndex, double radius, string? name = null, CancellationToken ct = default)
=> FilletEdgesAsync([edgeIndex], radius, name, ct);
/// <summary>Скруглить одним скруглением несколько рёбер по индексам из list_edges
/// (одна операция в дереве вместо N). Возвращает id операции.</summary>
public Task<int> FilletEdgesAsync(IReadOnlyList<int> edgeIndices, double radius, CancellationToken ct = default)
public Task<int> FilletEdgesAsync(IReadOnlyList<int> edgeIndices, double radius, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
// Диапазон всех индексов проверяем до мутации модели.
var edges = RequireEdges(part, edgeIndices);
return CreateFillet(part, edges, radius);
return CreateFillet(part, edges, radius, name);
}, ct);
/// <summary>Скруглить ОДНОЙ операцией рёбра, найденные по точкам (мировые координаты, мм).
/// Путь для тел, где список рёбер неподъёмен по объёму: N углов — одна операция, а не N.</summary>
public Task<int> FilletEdgesByPointsAsync(IReadOnlyList<Point3d> points, double radius, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var edges = RequireEdgesAt(part, points);
return CreateFillet(part, edges, radius, name);
}, ct);
/// <summary>Разобрать список индексов рёбер в сущности (с проверкой пустоты и диапазона).</summary>
@@ -179,14 +181,37 @@ public sealed partial class PartModeler
return indices.Select(i => SelectEdgeByIndex(part, i)).ToList();
}
private int CreateFillet(ksPart part, ksEntity edge, double radius) => CreateFillet(part, [edge], radius);
/// <summary>Найти рёбра по списку точек. Ненайденные перечисляются ВСЕ сразу: иначе агент
/// выясняет их по одной, теряя вызов на каждую точку.</summary>
private static List<ksEntity> RequireEdgesAt(ksPart part, IReadOnlyList<Point3d> points)
{
ArgumentNullException.ThrowIfNull(points);
if (points.Count == 0)
throw new ArgumentException("Нужна хотя бы одна точка на ребре.", nameof(points));
private int CreateFillet(ksPart part, IReadOnlyList<ksEntity> edges, double radius)
var found = new List<ksEntity>(points.Count);
var missing = new List<string>();
for (var i = 0; i < points.Count; i++)
{
var edge = TrySelectEdgeByPoint(part, points[i].X, points[i].Y, points[i].Z);
if (edge is null) missing.Add(FormattableString.Invariant($"[{i}] ({points[i].X}, {points[i].Y}, {points[i].Z})"));
else found.Add(edge);
}
if (missing.Count > 0)
throw new InvalidOperationException(
"Ребра нет в точках: " + string.Join("; ", missing) +
". Обычная причина — ребро уже съедено соседней операцией либо точка не лежит на ребре. " +
"Повторите вызов без этих точек или уточните координаты по list_edges.");
return found;
}
private int CreateFillet(ksPart part, IReadOnlyList<ksEntity> edges, double radius, string? name)
{
if (radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius), "Радиус скругления должен быть > 0.");
var entity = part.NewEntity((short)Obj3dType.o3d_fillet) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_fillet) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_fillet, name);
var def = entity.GetDefinition() as ksFilletDefinition
?? throw new InvalidOperationException("GetDefinition() скругления вернул не ksFilletDefinition.");
def.radius = radius;
@@ -198,44 +223,44 @@ public sealed partial class PartModeler
if (!entity.Create())
throw new InvalidOperationException("Create() скругления вернул FALSE (радиус слишком велик для ребра?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}
/// <summary>
/// Снять фаску с ребра детали, найденного по точке (мировые координаты, мм), катетом <paramref name="distance"/>.
/// Возвращает id операции.
/// </summary>
public Task<int> ChamferEdgeAsync(double x, double y, double z, double distance, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
return CreateChamfer(part, SelectEdgeByPoint(part, x, y, z), distance);
}, ct);
public Task<int> ChamferEdgeAsync(double x, double y, double z, double distance, string? name = null, CancellationToken ct = default)
=> ChamferEdgesByPointsAsync([new Point3d(x, y, z)], distance, name, ct);
/// <summary>Снять фаску с ребра детали по индексу из list_edges катетом <paramref name="distance"/>.</summary>
public Task<int> ChamferEdgeIndexAsync(int edgeIndex, double distance, CancellationToken ct = default)
=> ChamferEdgesAsync([edgeIndex], distance, ct);
public Task<int> ChamferEdgeIndexAsync(int edgeIndex, double distance, string? name = null, CancellationToken ct = default)
=> ChamferEdgesAsync([edgeIndex], distance, name, ct);
/// <summary>Снять одной операцией фаску с нескольких рёбер по индексам из list_edges.
/// Возвращает id операции.</summary>
public Task<int> ChamferEdgesAsync(IReadOnlyList<int> edgeIndices, double distance, CancellationToken ct = default)
public Task<int> ChamferEdgesAsync(IReadOnlyList<int> edgeIndices, double distance, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var edges = RequireEdges(part, edgeIndices);
return CreateChamfer(part, edges, distance);
return CreateChamfer(part, edges, distance, name);
}, ct);
private int CreateChamfer(ksPart part, ksEntity edge, double distance) => CreateChamfer(part, [edge], distance);
/// <summary>Снять ОДНОЙ операцией фаску с рёбер, найденных по точкам (мировые координаты, мм).</summary>
public Task<int> ChamferEdgesByPointsAsync(IReadOnlyList<Point3d> points, double distance, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var edges = RequireEdgesAt(part, points);
return CreateChamfer(part, edges, distance, name);
}, ct);
private int CreateChamfer(ksPart part, IReadOnlyList<ksEntity> edges, double distance)
private int CreateChamfer(ksPart part, IReadOnlyList<ksEntity> edges, double distance, string? name)
{
if (distance <= 0) throw new ArgumentOutOfRangeException(nameof(distance), "Катет фаски должен быть > 0.");
var entity = part.NewEntity((short)Obj3dType.o3d_chamfer) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_chamfer) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_chamfer, name);
var def = entity.GetDefinition() as ksChamferDefinition
?? throw new InvalidOperationException("GetDefinition() фаски вернул не ksChamferDefinition.");
def.tangent = false;
@@ -247,15 +272,13 @@ public sealed partial class PartModeler
if (!entity.Create())
throw new InvalidOperationException("Create() фаски вернул FALSE (катет слишком велик для ребра?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}
/// <summary>Превратить тело в оболочку: удалить (открыть) грани с индексами faceIndices,
/// задать толщину стенки thickness (мм). outward=false — толщина внутрь (габарит сохраняется),
/// true — наружу. Возвращает id операции.</summary>
public Task<int> ShellAsync(IReadOnlyList<int> faceIndices, double thickness, bool outward = false, CancellationToken ct = default)
public Task<int> ShellAsync(IReadOnlyList<int> faceIndices, double thickness, bool outward = false, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (thickness <= 0) throw new ArgumentOutOfRangeException(nameof(thickness), "Толщина должна быть > 0.");
@@ -267,8 +290,7 @@ public sealed partial class PartModeler
// Проверяем диапазон ВСЕХ индексов до мутации модели.
var faces = indices.Select(i => SelectFaceByIndex(part, i)).ToList();
var entity = part.NewEntity((short)Obj3dType.o3d_shellOperation) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_shellOperation) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_shellOperation, name);
var def = entity.GetDefinition() as ksShellDefinition
?? throw new InvalidOperationException("GetDefinition() оболочки вернул не ksShellDefinition.");
var arr = def.FaceArray() as ksEntityCollection
@@ -281,16 +303,14 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() оболочки вернул FALSE (толщина больше локального радиуса/стенки или несовместимая топология?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Построить ребро жёсткости от разомкнутого контура эскиза до тела.
/// thickness — толщина стенки (мм); side — направление достройки (left|right|up|down);
/// symmetric — толщина симметрична плоскости эскиза; angle — уклон стенок (градусы).
/// Возвращает id операции.</summary>
public Task<int> RibAsync(int sketchId, double thickness, string side = "up", bool symmetric = true, double angle = 0, CancellationToken ct = default)
public Task<int> RibAsync(int sketchId, double thickness, string side = "up", bool symmetric = true, double angle = 0, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
SketchGeometry.RequirePositive(thickness, nameof(thickness)); // ловит и NaN (!(NaN>0))
@@ -300,8 +320,7 @@ public sealed partial class PartModeler
CloseSketchCore(sketch); // эскиз должен быть закрыт перед операцией
var part = GetTopPart();
var entity = part.NewEntity((short)Obj3dType.o3d_ribOperation) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_ribOperation) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_ribOperation, name);
var def = entity.GetDefinition() as ksRibDefinition
?? throw new InvalidOperationException("GetDefinition() ребра вернул не ksRibDefinition.");
def.SetSketch(sketch.Entity);
@@ -317,14 +336,12 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() ребра вернул FALSE (контур не пересекает тело или неверное направление side?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Кинематическая операция (sweep): переместить профиль (эскиз profileSketchId)
/// вдоль траектории (эскиз pathSketchId), образуя сплошное тело. Возвращает id операции.</summary>
public Task<int> SweepAsync(int profileSketchId, int pathSketchId, CancellationToken ct = default)
public Task<int> SweepAsync(int profileSketchId, int pathSketchId, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (profileSketchId == pathSketchId)
@@ -335,8 +352,7 @@ public sealed partial class PartModeler
CloseSketchCore(path);
var part = GetTopPart();
var entity = part.NewEntity((short)Obj3dType.o3d_baseEvolution) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_baseEvolution) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_baseEvolution, name);
var def = entity.GetDefinition() as ksBaseEvolutionDefinition
?? throw new InvalidOperationException("GetDefinition() кинематической операции вернул не ksBaseEvolutionDefinition.");
def.SetSketch(profile.Entity);
@@ -350,14 +366,12 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() кинематической операции вернул FALSE (начало траектории не на плоскости профиля или несовместимая геометрия?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Операция по сечениям (loft): построить сплошное тело по ≥2 закрытым эскизам-сечениям
/// (в порядке списка). closed — замкнуть сечения в кольцо. Возвращает id операции.</summary>
public Task<int> LoftAsync(IReadOnlyList<int> sketchIds, bool closed = false, CancellationToken ct = default)
public Task<int> LoftAsync(IReadOnlyList<int> sketchIds, bool closed = false, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
ArgumentNullException.ThrowIfNull(sketchIds);
@@ -368,8 +382,7 @@ public sealed partial class PartModeler
foreach (var s in sketches) CloseSketchCore(s);
var part = GetTopPart();
var entity = part.NewEntity((short)Obj3dType.o3d_baseLoft) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_baseLoft) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_baseLoft, name);
var def = entity.GetDefinition() as ksBaseLoftDefinition
?? throw new InvalidOperationException("GetDefinition() операции по сечениям вернул не ksBaseLoftDefinition.");
var arr = def.Sketchs() as ksEntityCollection
@@ -382,15 +395,13 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() операции по сечениям вернул FALSE (несовместимые сечения или неверный порядок?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Линейный массив (по сетке вдоль одной координатной оси): размножить операции
/// featureIds вдоль оси axis, count экземпляров всего (включая исходный, ≥2), шаг step (мм)
/// между соседними. geometric=true — быстрое геометрическое копирование. Возвращает id операции.</summary>
public Task<int> LinearPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool geometric = false, CancellationToken ct = default)
public Task<int> LinearPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool geometric = false, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
ArgumentNullException.ThrowIfNull(featureIds);
@@ -406,8 +417,7 @@ public sealed partial class PartModeler
var axisEntity = part.GetDefaultEntity((short)CoordinateAxes.ToObj3dType(axis)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить координатную ось.");
var entity = part.NewEntity((short)Obj3dType.o3d_meshCopy) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_meshCopy) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_meshCopy, name);
var def = entity.GetDefinition() as ksMeshCopyDefinition
?? throw new InvalidOperationException("GetDefinition() массива вернул не ksMeshCopyDefinition.");
def.SetAxis1(axisEntity);
@@ -422,16 +432,14 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() линейного массива вернул FALSE (копии самопересекаются или несовместимая геометрия?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Круговой массив: размножить операции featureIds вокруг координатной оси axis,
/// count экземпляров всего (включая исходный, ≥2), угловой шаг step (градусы) между соседними.
/// reverse — обратное направление вращения. geometric=true — быстрое геометрическое копирование.
/// Возвращает id операции.</summary>
public Task<int> CircularPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool reverse = false, bool geometric = false, CancellationToken ct = default)
public Task<int> CircularPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool reverse = false, bool geometric = false, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
ArgumentNullException.ThrowIfNull(featureIds);
@@ -447,8 +455,7 @@ public sealed partial class PartModeler
var axisEntity = part.GetDefaultEntity((short)CoordinateAxes.ToObj3dType(axis)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить координатную ось.");
var entity = part.NewEntity((short)Obj3dType.o3d_circularCopy) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_circularCopy) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_circularCopy, name);
var def = entity.GetDefinition() as ksCircularCopyDefinition
?? throw new InvalidOperationException("GetDefinition() массива вернул не ksCircularCopyDefinition.");
def.SetAxis(axisEntity);
@@ -470,14 +477,12 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() кругового массива вернул FALSE (копии самопересекаются или неверная ось?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Зеркальная копия операций featureIds относительно координатной плоскости plane.
/// Возвращает id операции.</summary>
public Task<int> MirrorOperationAsync(IReadOnlyList<int> featureIds, BasePlane plane, CancellationToken ct = default)
public Task<int> MirrorOperationAsync(IReadOnlyList<int> featureIds, BasePlane plane, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
ArgumentNullException.ThrowIfNull(featureIds);
@@ -491,8 +496,7 @@ public sealed partial class PartModeler
var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(plane)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить координатную плоскость.");
var entity = part.NewEntity((short)Obj3dType.o3d_mirrorOperation) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_mirrorOperation) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_mirrorOperation, name);
var def = entity.GetDefinition() as ksMirrorCopyDefinition
?? throw new InvalidOperationException("GetDefinition() зеркала вернул не ksMirrorCopyDefinition.");
def.SetPlane(planeEntity);
@@ -504,22 +508,19 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() зеркальной копии вернул FALSE (копия пересекает исходную геометрию?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Зеркально отразить всё тело относительно координатной плоскости plane (получить
/// симметричную деталь: оригинал + зеркало). Возвращает id операции.</summary>
public Task<int> MirrorBodyAsync(BasePlane plane, CancellationToken ct = default)
public Task<int> MirrorBodyAsync(BasePlane plane, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(plane)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить координатную плоскость.");
var entity = part.NewEntity((short)Obj3dType.o3d_mirrorAllOperation) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_mirrorAllOperation) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_mirrorAllOperation, name);
var def = entity.GetDefinition() as ksMirrorCopyAllDefinition
?? throw new InvalidOperationException("GetDefinition() зеркала тела вернул не ksMirrorCopyAllDefinition.");
def.SetPlane(planeEntity);
@@ -531,16 +532,14 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() зеркального отражения тела вернул FALSE (тело пересекает плоскость симметрии?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Уклон граней: наклонить грани faceIndices на угол angle° относительно нейтральной
/// (опорной) координатной плоскости neutralPlane — сечение тела в этой плоскости остаётся
/// неизменным. outward=true — расширение (материал добавляется), false — сужение. Грани должны
/// граничить/пересекаться с нейтральной плоскостью. Возвращает id операции.</summary>
public Task<int> DraftAsync(IReadOnlyList<int> faceIndices, BasePlane neutralPlane, double angle, bool outward = false, CancellationToken ct = default)
public Task<int> DraftAsync(IReadOnlyList<int> faceIndices, BasePlane neutralPlane, double angle, bool outward = false, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
SketchGeometry.RequireDraftAngle(angle, nameof(angle));
@@ -555,8 +554,7 @@ public sealed partial class PartModeler
var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(neutralPlane)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить координатную плоскость.");
var entity = part.NewEntity((short)Obj3dType.o3d_incline) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_incline) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_incline, name);
var def = entity.GetDefinition() as ksInclineDefinition
?? throw new InvalidOperationException("GetDefinition() уклона вернул не ksInclineDefinition.");
var arr = def.FaceArray() as ksEntityCollection
@@ -570,9 +568,7 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() уклона вернул FALSE (грань не граничит с нейтральной плоскостью или несовместимая геометрия?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Перестроить документ.</summary>
+26 -5
View File
@@ -67,15 +67,14 @@ public sealed partial class PartModeler : IDisposable
=> ActiveDoc3D().GetPart((short)Part_Type.pTop_Part) as ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
/// <summary>Выбрать ребро детали по точке (мировые координаты). Берёт первое подходящее.</summary>
private static ksEntity SelectEdgeByPoint(ksPart part, double x, double y, double z)
/// <summary>Выбрать ребро детали по точке (мировые координаты). Берёт первое подходящее;
/// отсутствие ребра — не ошибка: пакетный выбор перечисляет все промахи разом.</summary>
private static ksEntity? TrySelectEdgeByPoint(ksPart part, double x, double y, double z)
{
var edges = part.EntityCollection((short)Obj3dType.o3d_edge) as ksEntityCollection
?? throw new InvalidOperationException("Не удалось получить коллекцию рёбер.");
edges.SelectByPoint(x, y, z);
if (edges.GetCount() == 0)
throw new InvalidOperationException(
$"В точке ({x}, {y}, {z}) не найдено ребра. Сделайте снимок и сверьте координаты точки на ребре.");
if (edges.GetCount() == 0) return null;
return edges.GetByIndex(0) as ksEntity
?? throw new InvalidOperationException("Ребро из коллекции не приводится к ksEntity.");
}
@@ -104,6 +103,28 @@ public sealed partial class PartModeler : IDisposable
?? throw new InvalidOperationException("Ребро по индексу не приводится к ksEntity.");
}
/// <summary>
/// Создать сущность детали и сразу дать ей имя в дереве построения. Имя присваивается ДО
/// <c>Create()</c>: КОМПАС иначе показывает безликое «Операция выдавливания:3», и агент,
/// вернувшийся к дереву через сотню вызовов, не может сказать, что именно строила операция.
/// </summary>
private static ksEntity NewEntity(ksPart part, Obj3dType type, string? name)
{
var entity = part.NewEntity((short)type) as ksEntity
?? throw new InvalidOperationException($"NewEntity({type}) вернул null.");
var normalized = FeatureName.Normalize(name);
if (normalized is not null) entity.name = normalized;
return entity;
}
/// <summary>Зарегистрировать созданную операцию под новым id (ссылки из массивов/зеркала).</summary>
private int Register(ksEntity entity)
{
var id = _nextId++;
_features[id] = entity;
return id;
}
private SketchEntry RequireSketch(int id)
=> _sketches.TryGetValue(id, out var s) ? s
: throw new KeyNotFoundException($"Эскиз с id={id} не найден.");
+7
View File
@@ -0,0 +1,7 @@
namespace Kompas.Mcp.Core.Modeling;
/// <summary>Точка в мировых координатах модели (мм) — для выбора рёбер/граней пакетом.</summary>
/// <param name="X">Координата X, мм.</param>
/// <param name="Y">Координата Y, мм.</param>
/// <param name="Z">Координата Z, мм.</param>
public readonly record struct Point3d(double X, double Y, double Z);
@@ -0,0 +1,44 @@
using System.ComponentModel;
using System.Text.Json.Serialization;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>
/// Точка на объекте модели во входе инструмента (мировые координаты, мм). Нужна там, где объекты
/// выбираются пакетом: список рёбер под одно скругление — это одна операция в дереве вместо N.
/// </summary>
public sealed class Point3dInput
{
[JsonPropertyName("x")]
[Description("X точки, мм")]
public double X { get; set; }
[JsonPropertyName("y")]
[Description("Y точки, мм")]
public double Y { get; set; }
[JsonPropertyName("z")]
[Description("Z точки, мм")]
public double Z { get; set; }
}
/// <summary>Разбор списка точек из входа инструмента.</summary>
public static class Point3dInputs
{
/// <summary>Преобразовать вход в точки модели, проверив конечность координат.</summary>
public static IReadOnlyList<Point3d> Parse(IReadOnlyList<Point3dInput>? points, string parameterName = "points")
{
if (points is null || points.Count == 0) return [];
var parsed = new List<Point3d>(points.Count);
for (var i = 0; i < points.Count; i++)
{
var p = points[i] ?? throw new ArgumentException($"Точка [{i}] пустая.", parameterName);
if (!double.IsFinite(p.X) || !double.IsFinite(p.Y) || !double.IsFinite(p.Z))
throw new ArgumentException($"Точка [{i}]: координаты должны быть конечными числами.", parameterName);
parsed.Add(new Point3d(p.X, p.Y, p.Z));
}
return parsed;
}
}
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Threading;
using Kompas6Constants3D;
@@ -32,7 +32,8 @@ public sealed class PrimitiveService
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)
PrimitiveResult result = PrimitiveResult.NewBody, bool forward = true,
string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
Primitives.Validate(kind, length, width, height, diameter);
@@ -56,6 +57,9 @@ public sealed class PrimitiveService
{
var elementary = (IElementaryBody)body;
elementary.OperationResult = Primitives.ToOperationResult(result);
// Имя ставим до Update(): иначе в дереве останется «Параллелепипед:2».
if (FeatureName.Normalize(name) is { } title && elementary is IModelObject named)
named.Name = title;
Place(elementary, x, y, z);
Configure(body, kind, length, width, height, diameter, forward);
+13 -9
View File
@@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Editing;
using Kompas.Mcp.Core.Modeling;
@@ -28,7 +28,8 @@ public sealed class EditTools(
[Description("Индекс грани из list_faces")] int? faceIndex = null,
[Description("X точки на грани, мм")] double? x = null,
[Description("Y точки на грани, мм")] double? y = null,
[Description("Z точки на грани, мм")] double? z = null)
[Description("Z точки на грани, мм")] double? z = null,
[Description("Имя операции в дереве построения, например «Наращивание торца»")] string? name = null)
{
await session.ConnectAsync();
@@ -50,7 +51,7 @@ public sealed class EditTools(
"Не задана грань: передайте faceIndex из list_faces (надёжнее) либо все три координаты точки на грани (x, y, z).");
}
await faceEdit.MoveFaceAsync(px, py, pz, distance);
await faceEdit.MoveFaceAsync(px, py, pz, distance, name);
return await autoValidation.AnnotateAsync($"{where} перемещена на {distance} мм вдоль нормали.");
}
@@ -61,10 +62,11 @@ public sealed class EditTools(
public async Task<string> SplitSolidByPlane(
[Description("Базовая плоскость: XOY | XOZ | YOZ")] string plane,
[Description("Смещение плоскости от базовой, мм")] double offset,
[Description("Прямое направление отступа")] bool direction = true)
[Description("Прямое направление отступа")] bool direction = true,
[Description("Имя операции в дереве построения, например «Рез под вставку 5 мм»")] string? name = null)
{
await session.ConnectAsync();
var bodies = await solidEdit.SplitByPlaneAsync(BasePlanes.Parse(plane), offset, direction);
var bodies = await solidEdit.SplitByPlaneAsync(BasePlanes.Parse(plane), offset, direction, name);
var lines = bodies.Select(b =>
$" [{b.Index}] габ {b.SizeX:F1}×{b.SizeY:F1}×{b.SizeZ:F1} мм, " +
$"Z[{b.MinZ:F1}; {b.MaxZ:F1}]");
@@ -74,10 +76,11 @@ public sealed class EditTools(
[McpServerTool(Name = "move_body")]
[Description("Переместить тело (по индексу из split_solid_by_plane) на вектор (dx, dy, dz) мм. " +
"Шаг 2 метода «рассечь→раздвинуть→объединить» — раздвигает части на нужный зазор.")]
public async Task<string> MoveBody(int bodyIndex, double dx = 0, double dy = 0, double dz = 0)
public async Task<string> MoveBody(int bodyIndex, double dx = 0, double dy = 0, double dz = 0,
[Description("Имя операции в дереве построения, например «Раздвижка верхней части»")] string? name = null)
{
await session.ConnectAsync();
await solidEdit.MoveBodyAsync(bodyIndex, dx, dy, dz);
await solidEdit.MoveBodyAsync(bodyIndex, dx, dy, dz, name);
return await autoValidation.AnnotateAsync($"Тело [{bodyIndex}] перемещено на ({dx}, {dy}, {dz}) мм.");
}
@@ -85,10 +88,11 @@ public sealed class EditTools(
[Description("Объединить все тела активной детали в одно (булева операция «объединение»). " +
"Шаг 4 (финал) метода «рассечь→раздвинуть→объединить»: после стыковки граней (move_face) " +
"сливает части в единое тело.")]
public async Task<string> BooleanUnion()
public async Task<string> BooleanUnion(
[Description("Имя операции в дереве построения, например «Сшивка частей корпуса»")] string? name = null)
{
await session.ConnectAsync();
await solidEdit.UnionAllAsync();
await solidEdit.UnionAllAsync(name);
return await autoValidation.AnnotateAsync("Тела объединены.");
}
}
+76 -43
View File
@@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
@@ -35,13 +35,14 @@ public sealed class FeatureTools(
[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)
[Description("Прямое направление высоты (иначе вниз от точки привязки)")] bool forward = true,
[Description("Имя операции в дереве построения, например «Плашка 82×10.5»")] string? name = null)
{
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);
await primitives.PrimitiveAsync(parsedKind, x, y, z, length, width, height, diameter, parsedResult, forward, name);
var what = parsedKind.ToString().ToLowerInvariant();
var how = parsedResult switch
@@ -68,13 +69,14 @@ public sealed class FeatureTools(
[Description("Вырезать насквозь через всю деталь (только для cut)")] bool throughAll = false,
[Description("Прямое направление")] bool forward = true,
[Description("Толщина тонкой стенки, мм; 0 — сплошное сечение по контуру")] double thinThickness = 0,
[Description("Сторона стенки от контура: outward | inward | both")] string thinSide = "outward")
[Description("Сторона стенки от контура: outward | inward | both")] string thinSide = "outward",
[Description("Имя операции в дереве построения, например «Рельеф букв OldMan»")] string? name = null)
{
await session.ConnectAsync();
var cut = ParseCutMode(mode);
var side = ThinSides.Parse(thinSide);
var id = await modeler.ExtrudeAsync(sketchId, depth, cut: cut, forward: forward, throughAll: throughAll,
thinThickness: thinThickness, thinSide: side);
thinThickness: thinThickness, thinSide: side, name: name);
var what = cut ? "Вырез" : "Выдавливание";
var how = throughAll ? "насквозь" : $"на {depth} мм";
var wall = thinThickness > 0 ? $", стенка {thinThickness} мм ({side.ToString().ToLowerInvariant()})" : "";
@@ -89,62 +91,85 @@ public sealed class FeatureTools(
[Description("Id эскиза с профилем и осевой линией")] int sketchId,
[Description("Режим: boss (добавить материал) | cut (вырезать)")] string mode,
[Description("Угол вращения, градусы (0..360]")] double angle = 360,
[Description("Прямое направление")] bool forward = true)
[Description("Прямое направление")] bool forward = true,
[Description("Имя операции в дереве построения, например «Тело вращения корпуса»")] string? name = null)
{
await session.ConnectAsync();
var cut = ParseCutMode(mode);
var id = await modeler.RevolveAsync(sketchId, angle, cut: cut, forward: forward);
var id = await modeler.RevolveAsync(sketchId, angle, cut: cut, forward: forward, name: name);
var what = cut ? "Вырез вращением" : "Вращение";
return await autoValidation.AnnotateAsync($"{what} на {angle}° создан(о), id={id}.");
}
[McpServerTool(Name = "fillet_edge")]
[Description("Скруглить рёбра детали радиусом radius (мм) — одной операцией. Рёбра задаются ЛИБО списком " +
"индексов edgeIndices из list_edges (предпочтительно: не нужно угадывать координаты, за один " +
"вызов скругляется хоть весь контур), ЛИБО точкой x,y,z на ребре в мировых координатах модели " +
"(запасной вариант — тогда скругляется одно ребро). Возвращает id операции.")]
[Description("Скруглить рёбра детали радиусом radius (мм) — ОДНОЙ операцией. Рёбра задаются любым из " +
"способов: edgeIndices — список индексов из list_edges (предпочтительно, когда список рёбер " +
"обозрим); points — список точек {x,y,z} на рёбрах (для тел с сотнями рёбер, где list_edges " +
"неподъёмен: все углы скругляются одной операцией, а не N вызовами); x,y,z — одна точка. " +
"Если ребра нет в части точек, ошибка перечислит ВСЕ такие точки сразу. Возвращает id операции.")]
public async Task<string> FilletEdge(
[Description("Радиус скругления, мм")] double radius,
[Description("Индексы рёбер из list_edges")] int[]? edgeIndices = null,
[Description("Точки на рёбрах: [{x,y,z}, …] одна операция на все")] Point3dInput[]? points = null,
[Description("X точки на ребре, мм")] double? x = null,
[Description("Y точки на ребре, мм")] double? y = null,
[Description("Z точки на ребре, мм")] double? z = null)
[Description("Z точки на ребре, мм")] double? z = null,
[Description("Имя операции в дереве построения, например «Скругления углов плашки»")] string? name = null)
{
await session.ConnectAsync();
if (edgeIndices is { Length: > 0 })
{
var id = await modeler.FilletEdgesAsync(edgeIndices, radius);
var id = await modeler.FilletEdgesAsync(edgeIndices, radius, name);
var count = edgeIndices.Distinct().Count();
return await autoValidation.AnnotateAsync($"Скругление R{radius} создано на {count} рёбрах, id={id}.");
}
var (px, py, pz) = RequirePoint(x, y, z, "ребре", "edgeIndices");
var byPoint = await modeler.FilletEdgeAsync(px, py, pz, radius);
var parsed = Point3dInputs.Parse(points);
if (parsed.Count > 0)
{
var id = await modeler.FilletEdgesByPointsAsync(parsed, radius, name);
return await autoValidation.AnnotateAsync(
$"Скругление R{radius} создано на {parsed.Count} рёбрах (по точкам), id={id}.");
}
var (px, py, pz) = RequirePoint(x, y, z, "ребре", "edgeIndices/points");
var byPoint = await modeler.FilletEdgeAsync(px, py, pz, radius, name);
return await autoValidation.AnnotateAsync(
$"Скругление R{radius} создано на ребре в точке ({px}, {py}, {pz}), id={byPoint}.");
}
[McpServerTool(Name = "chamfer_edge")]
[Description("Снять фаску с рёбер детали катетом distance (мм) — одной операцией. Рёбра задаются ЛИБО " +
"списком индексов edgeIndices из list_edges (предпочтительно), ЛИБО точкой x,y,z на ребре " +
"в мировых координатах модели (тогда обрабатывается одно ребро). Возвращает id операции.")]
[Description("Снять фаску с рёбер детали катетом distance (мм) — ОДНОЙ операцией. Рёбра задаются любым " +
"из способов: edgeIndices — список индексов из list_edges; points — список точек {x,y,z} " +
"на рёбрах (одна операция на все, путь для тел с сотнями рёбер); x,y,z — одна точка. " +
"Возвращает id операции.")]
public async Task<string> ChamferEdge(
[Description("Катет фаски, мм")] double distance,
[Description("Индексы рёбер из list_edges")] int[]? edgeIndices = null,
[Description("Точки на рёбрах: [{x,y,z}, …] одна операция на все")] Point3dInput[]? points = null,
[Description("X точки на ребре, мм")] double? x = null,
[Description("Y точки на ребре, мм")] double? y = null,
[Description("Z точки на ребре, мм")] double? z = null)
[Description("Z точки на ребре, мм")] double? z = null,
[Description("Имя операции в дереве построения, например «Фаска против слоновьей ноги»")] string? name = null)
{
await session.ConnectAsync();
if (edgeIndices is { Length: > 0 })
{
var id = await modeler.ChamferEdgesAsync(edgeIndices, distance);
var id = await modeler.ChamferEdgesAsync(edgeIndices, distance, name);
var count = edgeIndices.Distinct().Count();
return await autoValidation.AnnotateAsync($"Фаска {distance}×{distance} создана на {count} рёбрах, id={id}.");
}
var (px, py, pz) = RequirePoint(x, y, z, "ребре", "edgeIndices");
var byPoint = await modeler.ChamferEdgeAsync(px, py, pz, distance);
var parsed = Point3dInputs.Parse(points);
if (parsed.Count > 0)
{
var id = await modeler.ChamferEdgesByPointsAsync(parsed, distance, name);
return await autoValidation.AnnotateAsync(
$"Фаска {distance}×{distance} создана на {parsed.Count} рёбрах (по точкам), id={id}.");
}
var (px, py, pz) = RequirePoint(x, y, z, "ребре", "edgeIndices/points");
var byPoint = await modeler.ChamferEdgeAsync(px, py, pz, distance, name);
return await autoValidation.AnnotateAsync(
$"Фаска {distance}×{distance} создана на ребре в точке ({px}, {py}, {pz}), id={byPoint}.");
}
@@ -154,29 +179,32 @@ public sealed class FeatureTools(
public async Task<string> Shell(
[Description("Индексы удаляемых (открываемых) граней из list_faces")] int[] faceIndices,
[Description("Толщина стенки, мм")] double thickness,
[Description("Толщина наружу (true) или внутрь (false)")] bool outward = false)
[Description("Толщина наружу (true) или внутрь (false)")] bool outward = false,
[Description("Имя операции в дереве построения, например «Оболочка корпуса 1.6»")] string? name = null)
{
await session.ConnectAsync();
var id = await modeler.ShellAsync(faceIndices, thickness, outward);
var id = await modeler.ShellAsync(faceIndices, thickness, outward, name);
var removed = faceIndices?.Distinct().Count() ?? 0;
return await autoValidation.AnnotateAsync($"Оболочка толщиной {thickness} мм создана (удалено граней: {removed}), id={id}.");
}
[McpServerTool(Name = "rib")]
[Description("Построить ребро жёсткости от разомкнутого контура эскиза до тела. sketchId — эскиз с разомкнутым контуром (отрезок/ломаная) на плоскости, пересекающей тело. thickness — толщина стенки ребра (мм). side — направление достройки полотна до тела (ksRibSideEnum): left|right|up|down (если ребро не строится — перебрать значения). symmetric=true — толщина симметрична плоскости эскиза, false — в одну сторону. angle — уклон стенок (градусы). Возвращает id операции.")]
public async Task<string> Rib(int sketchId, double thickness, string side = "up", bool symmetric = true, double angle = 0)
public async Task<string> Rib(int sketchId, double thickness, string side = "up", bool symmetric = true, double angle = 0,
[Description("Имя операции в дереве построения, например «Ребро жёсткости левое»")] string? name = null)
{
await session.ConnectAsync();
var id = await modeler.RibAsync(sketchId, thickness, side, symmetric, angle);
var id = await modeler.RibAsync(sketchId, thickness, side, symmetric, angle, name);
return await autoValidation.AnnotateAsync($"Ребро жёсткости толщиной {thickness} мм создано (side={side}), id={id}.");
}
[McpServerTool(Name = "sweep")]
[Description("Кинематическая операция: переместить замкнутый профиль (эскиз profileSketchId) вдоль траектории (эскиз pathSketchId), образуя сплошное тело. Профиль и траектория должны быть совместимы: на разных плоскостях (обычно перпендикулярных), начало траектории — на плоскости профиля; иначе операция вернёт ошибку построения. Возвращает id операции.")]
public async Task<string> Sweep(int profileSketchId, int pathSketchId)
public async Task<string> Sweep(int profileSketchId, int pathSketchId,
[Description("Имя операции в дереве построения, например «Труба по траектории»")] string? name = null)
{
await session.ConnectAsync();
var id = await modeler.SweepAsync(profileSketchId, pathSketchId);
var id = await modeler.SweepAsync(profileSketchId, pathSketchId, name);
return await autoValidation.AnnotateAsync(
$"Кинематическая операция создана (профиль {profileSketchId} по траектории {pathSketchId}), id={id}.");
}
@@ -185,10 +213,11 @@ public sealed class FeatureTools(
[Description("Операция по сечениям (loft): построить сплошное тело по нескольким закрытым эскизам-сечениям (sketchIds, минимум 2, в порядке списка). Сечения обычно на параллельных плоскостях — задавайте их через sketch_create с plane + offset. closed=true — замкнуть сечения в кольцо. Возвращает id операции.")]
public async Task<string> Loft(
[Description("Id эскизов-сечений (минимум 2, в порядке построения)")] int[] sketchIds,
[Description("Замкнуть сечения в кольцо")] bool closed = false)
[Description("Замкнуть сечения в кольцо")] bool closed = false,
[Description("Имя операции в дереве построения, например «Переход по сечениям»")] string? name = null)
{
await session.ConnectAsync();
var id = await modeler.LoftAsync(sketchIds, closed);
var id = await modeler.LoftAsync(sketchIds, closed, name);
var sections = sketchIds?.Distinct().Count() ?? 0;
return await autoValidation.AnnotateAsync($"Операция по сечениям создана ({sections} сечений), id={id}.");
}
@@ -207,7 +236,8 @@ public sealed class FeatureTools(
[Description("Число экземпляров всего, включая исходный (>=2)")] int count,
[Description("Шаг между соседними: мм для linear, градусы для circular")] double step,
[Description("Обратное направление вращения (только circular)")] bool reverse = false,
[Description("Геометрическое копирование (быстрее)")] bool geometric = false)
[Description("Геометрическое копирование (быстрее)")] bool geometric = false,
[Description("Имя операции в дереве построения, например «Массив отверстий крепления»")] string? name = null)
{
await session.ConnectAsync();
var circular = ParsePatternKind(kind);
@@ -215,11 +245,11 @@ public sealed class FeatureTools(
if (circular)
{
var id = await modeler.CircularPatternAsync(featureIds, parsedAxis, count, step, reverse, geometric);
var id = await modeler.CircularPatternAsync(featureIds, parsedAxis, count, step, reverse, geometric, name);
return await autoValidation.AnnotateAsync($"Круговой массив создан ({count} экз. вокруг {axis}, шаг {step}°), id={id}.");
}
var linearId = await modeler.LinearPatternAsync(featureIds, parsedAxis, count, step, geometric);
var linearId = await modeler.LinearPatternAsync(featureIds, parsedAxis, count, step, geometric, name);
return await autoValidation.AnnotateAsync($"Линейный массив создан ({count} экз. вдоль {axis}, шаг {step} мм), id={linearId}.");
}
@@ -230,18 +260,19 @@ public sealed class FeatureTools(
"сохраняются). Возвращает id операции.")]
public async Task<string> Mirror(
[Description("Плоскость симметрии: XOY|XOZ|YOZ")] string plane,
[Description("Id отражаемых операций; пусто — отразить всё тело")] int[]? featureIds = null)
[Description("Id отражаемых операций; пусто — отразить всё тело")] int[]? featureIds = null,
[Description("Имя операции в дереве построения, например «Зеркало кронштейна»")] string? name = null)
{
await session.ConnectAsync();
var basePlane = BasePlanes.Parse(plane);
if (featureIds is { Length: > 0 })
{
var id = await modeler.MirrorOperationAsync(featureIds, basePlane);
var id = await modeler.MirrorOperationAsync(featureIds, basePlane, name);
return await autoValidation.AnnotateAsync($"Зеркальная копия операций создана (плоскость {plane}), id={id}.");
}
var bodyId = await modeler.MirrorBodyAsync(basePlane);
var bodyId = await modeler.MirrorBodyAsync(basePlane, name);
return await autoValidation.AnnotateAsync($"Тело зеркально отражено (плоскость {plane}), id={bodyId}.");
}
@@ -269,7 +300,8 @@ public sealed class FeatureTools(
[Description("Глубина цековки, мм — для counterbore")] double? spotfaceDepth = null,
[Description("Диаметр зенковки, мм (> diameter) — для countersink")] double? sinkDiameter = null,
[Description("Угол конуса зенковки, градусы (0..180, типично 90) — для countersink")] double? sinkAngle = null,
[Description("Угол конуса, градусы (0..180) — для conic")] double? conicAngle = null)
[Description("Угол конуса, градусы (0..180) — для conic")] double? conicAngle = null,
[Description("Имя операции в дереве построения, например «Отверстие под M4»")] string? name = null)
{
await session.ConnectAsync();
var kind = (type ?? "simple").Trim().ToLowerInvariant();
@@ -280,26 +312,26 @@ public sealed class FeatureTools(
switch (kind)
{
case "simple":
await holes.HoleAsync(px, py, pz, diameter, depth, throughAll);
await holes.HoleAsync(px, py, pz, diameter, depth, throughAll, name);
return await autoValidation.AnnotateAsync($"Отверстие Ø{diameter} ({how}) создано {where}.");
case "counterbore":
var spotD = Required(spotfaceDiameter, "spotfaceDiameter", kind);
var spotH = Required(spotfaceDepth, "spotfaceDepth", kind);
await holes.CounterboreHoleAsync(px, py, pz, diameter, depth, throughAll, spotD, spotH);
await holes.CounterboreHoleAsync(px, py, pz, diameter, depth, throughAll, spotD, spotH, name);
return await autoValidation.AnnotateAsync(
$"Отверстие с цековкой Ø{diameter} (цековка Ø{spotD}×{spotH}, {how}) создано {where}.");
case "countersink":
var sinkD = Required(sinkDiameter, "sinkDiameter", kind);
var sinkA = Required(sinkAngle, "sinkAngle", kind);
await holes.CountersinkHoleAsync(px, py, pz, diameter, depth, throughAll, sinkD, sinkA);
await holes.CountersinkHoleAsync(px, py, pz, diameter, depth, throughAll, sinkD, sinkA, name);
return await autoValidation.AnnotateAsync(
$"Отверстие с зенковкой Ø{diameter} (зенковка Ø{sinkD}, угол {sinkA}°, {how}) создано {where}.");
case "conic":
var conicA = Required(conicAngle, "conicAngle", kind);
await holes.ConicHoleAsync(px, py, pz, diameter, depth, throughAll, conicA);
await holes.ConicHoleAsync(px, py, pz, diameter, depth, throughAll, conicA, name);
return await autoValidation.AnnotateAsync(
$"Коническое отверстие Ø{diameter} (угол {conicA}°, {how}) создано {where}.");
@@ -315,10 +347,11 @@ public sealed class FeatureTools(
[Description("Индексы уклоняемых граней из list_faces")] int[] faceIndices,
[Description("Нейтральная (опорная) плоскость: XOY|XOZ|YOZ")] string neutralPlane,
[Description("Угол уклона, градусы (0 < angle < 90)")] double angle,
[Description("Расширение (true) или сужение (false)")] bool outward = false)
[Description("Расширение (true) или сужение (false)")] bool outward = false,
[Description("Имя операции в дереве построения, например «Уклон стенок под съём»")] string? name = null)
{
await session.ConnectAsync();
var id = await modeler.DraftAsync(faceIndices, BasePlanes.Parse(neutralPlane), angle, outward);
var id = await modeler.DraftAsync(faceIndices, BasePlanes.Parse(neutralPlane), angle, outward, name);
var count = faceIndices?.Distinct().Count() ?? 0;
return await autoValidation.AnnotateAsync(
$"Уклон {angle}° создан на {count} гранях (нейтральная плоскость {neutralPlane}), id={id}.");
@@ -0,0 +1,50 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Имя операции в дереве построения. Дерево из «Эскиз:1…Эскиз:5» нечитаемо, поэтому имя
/// принимается у каждой операции — но кривое имя (с переводом строки, гигантское) в дереве
/// отображается искажённо, и лучше сказать об этом сразу.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FeatureNameTests
{
[Fact]
public void Name_is_trimmed()
=> Assert.Equal("Контур плашки", FeatureName.Normalize(" Контур плашки "));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Empty_name_means_leave_the_kompas_default(string? name)
=> Assert.Null(FeatureName.Normalize(name));
[Fact]
public void Too_long_name_is_rejected_with_the_limit_in_the_message()
{
var ex = Assert.Throws<ArgumentException>(() => FeatureName.Normalize(new string('я', FeatureName.MaxLength + 1)));
Assert.Contains(FeatureName.MaxLength.ToString(), ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Name_at_the_limit_is_accepted()
{
var name = new string('я', FeatureName.MaxLength);
Assert.Equal(name, FeatureName.Normalize(name));
}
[Theory]
[InlineData("Эскиз\nвторая строка")]
[InlineData("Эскиз\tтабуляция")]
public void Control_characters_are_rejected(string name)
=> Assert.Throws<ArgumentException>(() => FeatureName.Normalize(name));
[Fact]
public void Parameter_name_reaches_the_message()
{
var ex = Assert.Throws<ArgumentException>(() => FeatureName.Normalize("a\nb", "sketchName"));
Assert.Equal("sketchName", ex.ParamName);
}
}
@@ -0,0 +1,86 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Интеграция: имя операции в дереве построения. Без имени дерево — «Эскиз:1», «Элемент
/// выдавливания:1», и вернувшийся к модели агент (или человек) не знает, что чем построено.
/// Имя ставится ДО Create()/Update() и должно читаться обратно через дерево (list_features).
/// </summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class FeatureNamingTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PartModeler _modeler;
private readonly PrimitiveService _primitives;
private readonly ModelInspectionService _inspection;
public FeatureNamingTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_primitives = new PrimitiveService(fx.Session, fx.Dispatcher);
_inspection = new ModelInspectionService(fx.Session, fx.Dispatcher);
}
private async Task<IReadOnlyList<string>> TreeNamesAsync()
=> (await _inspection.DescribeModelAsync(InspectionText.ModelSections.Tree))
.Features.Select(f => f.Name).ToList();
[Fact]
public async Task Sketch_and_extrusion_carry_their_names_into_the_tree()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Контур плашки");
await _modeler.AddRectangleAsync(s, 0, 0, 20, 10);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 3, name: "Плашка 20×10×3");
await _modeler.RebuildAsync();
var names = await TreeNamesAsync();
Assert.Contains("Контур плашки", names);
Assert.Contains("Плашка 20×10×3", names);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Primitive_carries_its_name_into_the_tree()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
await _primitives.PrimitiveAsync(PrimitiveKind.Block, 0, 0, 0,
length: 20, width: 10, height: 3, name: "Основание корпуса");
var names = await TreeNamesAsync();
Assert.Contains("Основание корпуса", names);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Without_a_name_kompas_keeps_its_own()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddRectangleAsync(s, 0, 0, 20, 10);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 3);
await _modeler.RebuildAsync();
// Имя не задано — дерево остаётся с системными именами, но узлы на месте.
var names = await TreeNamesAsync();
Assert.NotEmpty(names);
Assert.All(names, n => Assert.False(string.IsNullOrWhiteSpace(n)));
}
finally { await _docs.CloseAsync(save: false); }
}
}
@@ -0,0 +1,88 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Интеграция: скругление пакетом по ТОЧКАМ. На теле с сотнями рёбер (надпись!) список из
/// list_edges неподъёмен по объёму ответа, а выбор по точке раньше брал ровно одно ребро —
/// четыре угла давали четыре операции в дереве. Теперь это одна операция.
/// </summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class FilletByPointsTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PartModeler _modeler;
private readonly ModelInspectionService _inspection;
private readonly QueryService _query;
public FilletByPointsTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_inspection = new ModelInspectionService(fx.Session, fx.Dispatcher);
_query = new QueryService(fx.Session, fx.Dispatcher);
}
/// <summary>Плита 20×10×3 от начала координат: вертикальные рёбра — в её четырёх углах.</summary>
private async Task PlateAsync()
{
await _docs.CreateAsync(KompasDocumentType.Part);
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Контур плиты");
await _modeler.AddRectangleAsync(s, 0, 0, 20, 10);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 3, name: "Плита");
await _modeler.RebuildAsync();
}
[Fact]
public async Task Four_corners_become_one_fillet_in_the_tree()
{
await PlateAsync();
try
{
var before = (await _query.GetPartInfoAsync()).Volume;
await _modeler.FilletEdgesByPointsAsync(
[
new Point3d(0, 0, 1.5), new Point3d(20, 0, 1.5),
new Point3d(20, 10, 1.5), new Point3d(0, 10, 1.5),
], radius: 2, name: "Скругления углов плиты");
await _modeler.RebuildAsync();
var tree = (await _inspection.DescribeModelAsync(InspectionText.ModelSections.Tree)).Features;
Assert.Single(tree, f => f.Name == "Скругления углов плиты");
// Каждый угол теряет (4 − π)·r²/4 · h материала — проверяем, что скруглены все четыре.
var expected = before - 4 * (4 - Math.PI) / 4 * 2 * 2 * 3;
Assert.InRange((await _query.GetPartInfoAsync()).Volume, expected * 0.99, expected * 1.01);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Points_without_an_edge_are_all_listed_at_once()
{
await PlateAsync();
try
{
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _modeler.FilletEdgesByPointsAsync(
[
new Point3d(0, 0, 1.5), // ребро есть
new Point3d(100, 100, 100), // пусто
new Point3d(-50, -50, -50), // пусто
], radius: 1));
// Обе промахнувшиеся точки названы в ОДНОМ сообщении: иначе агент выясняет их по одной.
Assert.Contains("[1]", ex.Message, StringComparison.Ordinal);
Assert.Contains("[2]", ex.Message, StringComparison.Ordinal);
// Модель не тронута — операция не создавалась.
var tree = (await _inspection.DescribeModelAsync(InspectionText.ModelSections.Tree)).Features;
Assert.DoesNotContain(tree, f => f.Name.Contains("Скругление", StringComparison.OrdinalIgnoreCase));
}
finally { await _docs.CloseAsync(save: false); }
}
}
@@ -0,0 +1,43 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Разбор списка точек: рёбра выбираются пакетом, чтобы N углов давали ОДНУ операцию в дереве.
/// Ошибка обязана называть позицию в пакете — иначе агент гадает, какая из точек кривая.
/// </summary>
[Trait("Category", "Unit")]
public sealed class Point3dInputsTests
{
[Fact]
public void Points_are_parsed_in_order()
{
var parsed = Point3dInputs.Parse(
[
new Point3dInput { X = 1, Y = 2, Z = 3 },
new Point3dInput { X = 4, Y = 5, Z = 6 },
]);
Assert.Equal(new Point3d(1, 2, 3), parsed[0]);
Assert.Equal(new Point3d(4, 5, 6), parsed[1]);
}
[Fact]
public void Absent_list_is_not_an_error_but_an_empty_result()
{
Assert.Empty(Point3dInputs.Parse(null));
Assert.Empty(Point3dInputs.Parse([]));
}
[Fact]
public void Non_finite_coordinate_names_its_position()
{
var ex = Assert.Throws<ArgumentException>(() => Point3dInputs.Parse(
[
new Point3dInput { X = 0, Y = 0, Z = 0 },
new Point3dInput { X = double.NaN, Y = 0, Z = 0 },
]));
Assert.Contains("[1]", ex.Message, StringComparison.Ordinal);
}
}