feat(features): скругление и фаска ребра (fillet_edge / chamfer_edge)

PartModeler.FilletEdgeAsync / ChamferEdgeAsync через API5 o3d_fillet/o3d_chamfer:
ребро выбирается по точке (общий хелпер SelectEdgeByPoint поверх
EntityCollection(o3d_edge).SelectByPoint), добавляется в def.array(),
радиус/катет задаются (ksFilletDefinition.radius, ksChamferDefinition.SetChamferParam).
Новые MCP-инструменты: fillet_edge, chamfer_edge.

Тест Fillet_and_chamfer_edges: цилиндр → скругление верхнего ребра R3 + фаска нижнего 2×2,
проверка успеха, уменьшения объёма и снимка (визуально подтверждено).
Презентация: 25 инструментов, 17 тестов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@
This commit is contained in:
2026-05-26 10:01:26 +03:00
parent 5a689ad461
commit 88dad09ea7
4 changed files with 130 additions and 6 deletions
+6 -6
View File
@@ -125,7 +125,7 @@
<!-- HERO -->
<header class="hero">
<div class="wrap">
<span class="badge"><span class="dot" style="background:var(--green);box-shadow:0 0 10px var(--green)"></span> Статус: v1 работает — цикл эскиз→выдавливание/вращение→снимок через MCP (23 инструмента)</span>
<span class="badge"><span class="dot" style="background:var(--green);box-shadow:0 0 10px var(--green)"></span> Статус: v1 работает — цикл эскиз→операции→снимок через MCP (25 инструментов)</span>
<h1>КОМПАС-3D <span class="g">MCP-сервер</span><br>управление CAD языком LLM</h1>
<p class="lead">MCP-сервер, который превращает операции КОМПАС-3D — создание документов,
эскизы, 3D-операции, параметры — в инструменты для языковой модели. Под капотом —
@@ -246,8 +246,8 @@
впереди.</p>
<div class="prog">
<div class="progrow"><span>Общий прогресс</span><span>v1 + эскиз на грани + вращение · 16 тестов зелёные · доки и ревью</span></div>
<div class="bar"><i style="width:78%"></i></div>
<div class="progrow"><span>Общий прогресс</span><span>v2: грань · вращение · скругление/фаска · 17 тестов зелёные</span></div>
<div class="bar"><i style="width:80%"></i></div>
</div>
<div class="todo-grid" style="margin-top:26px">
@@ -282,12 +282,12 @@
<!-- TOOLS -->
<div class="panel">
<h3>Инструменты v1 <span class="tag done">ГОТОВО</span></h3>
<p class="meta">23 инструмента, отдаются по MCP-протоколу</p>
<p class="meta">25 инструментов, отдаются по MCP-протоколу</p>
<ul class="list">
<li class="muted"><span class="mark ok"></span><span class="t"><code>System</code>: connect · status · set_visible</span></li>
<li class="muted"><span class="mark ok"></span><span class="t"><code>Documents</code>: create · open · save · save_as · close · active</span></li>
<li class="muted"><span class="mark ok"></span><span class="t"><code>Sketch</code>: create · create_on_face · add line/circle/rectangle/axis · close</span></li>
<li class="muted"><span class="mark ok"></span><span class="t"><code>Features</code>: extrude_boss/cut · revolve_boss/cut 🆕 · rebuild</span></li>
<li class="muted"><span class="mark ok"></span><span class="t"><code>Features</code>: extrude · revolve · fillet_edge/chamfer_edge 🆕 · rebuild</span></li>
<li class="muted"><span class="mark ok"></span><span class="t"><code>Vision</code>: model_snapshot 👁️</span></li>
<li class="muted"><span class="mark ok"></span><span class="t"><code>Query</code>: get_part_info (объём/масса/площадь/центр масс)</span></li>
<li><span class="mark todo"></span><span><code>Query</code>: select_face · list_bodies (далее)</span></li>
@@ -319,7 +319,7 @@
черчению и сборкам.</p>
<div class="timeline">
<div class="tl" style="border-color:var(--green)"><div class="ph" style="color:var(--green)">v1 · готово ✓</div><h4>Цикл моделирования</h4><p>Документы, эскизы, выдавливание/вырез, снимок модели — работает</p></div>
<div class="tl cur"><div class="ph">v2 · в работе</div><h4>Операции и выборка</h4><p>Эскиз на грани ✓ · вращение ✓ · далее: скругление/фаска, массивы, список тел</p></div>
<div class="tl cur"><div class="ph">v2 · в работе</div><h4>Операции и выборка</h4><p>Эскиз на грани ✓ · вращение ✓ · скругление/фаска ✓ · далее: массивы, список тел</p></div>
<div class="tl"><div class="ph">v3</div><h4>Параметрика</h4><p>Переменные <code>IVariable7</code>, свойства <code>IPropertyMng</code>, формулы</p></div>
<div class="tl"><div class="ph">v4</div><h4>2D-черчение</h4><p>Виды, размеры, тексты, штриховки; спецификации</p></div>
<div class="tl"><div class="ph">v5</div><h4>Сборки и экспорт</h4><p>Компоненты, сопряжения, конвертеры (STEP), HTTP/SSE-транспорт</p></div>
@@ -246,6 +246,64 @@ public sealed class PartModeler : IDisposable
return id;
}, ct);
/// <summary>
/// Скруглить ребро детали, найденное по точке (мировые координаты, мм), радиусом <paramref name="radius"/>.
/// Возвращает id операции.
/// </summary>
public Task<int> FilletEdgeAsync(double x, double y, double z, double radius, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius), "Радиус скругления должен быть > 0.");
var part = GetTopPart();
var edge = SelectEdgeByPoint(part, x, y, z);
var entity = part.NewEntity((short)Obj3dType.o3d_fillet) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_fillet) вернул null.");
var def = entity.GetDefinition() as ksFilletDefinition
?? throw new InvalidOperationException("GetDefinition() скругления вернул не ksFilletDefinition.");
def.radius = radius;
def.tangent = false;
var arr = def.array() as ksEntityCollection
?? throw new InvalidOperationException("array() скругления вернул не ksEntityCollection.");
arr.Add(edge);
if (!entity.Create())
throw new InvalidOperationException("Create() скругления вернул FALSE (радиус слишком велик для ребра?).");
var id = _nextId++;
_features[id] = entity;
return id;
}, ct);
/// <summary>
/// Снять фаску с ребра детали, найденного по точке (мировые координаты, мм), катетом <paramref name="distance"/>.
/// Возвращает id операции.
/// </summary>
public Task<int> ChamferEdgeAsync(double x, double y, double z, double distance, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (distance <= 0) throw new ArgumentOutOfRangeException(nameof(distance), "Катет фаски должен быть > 0.");
var part = GetTopPart();
var edge = SelectEdgeByPoint(part, x, y, z);
var entity = part.NewEntity((short)Obj3dType.o3d_chamfer) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_chamfer) вернул null.");
var def = entity.GetDefinition() as ksChamferDefinition
?? throw new InvalidOperationException("GetDefinition() фаски вернул не ksChamferDefinition.");
def.tangent = false;
def.SetChamferParam(true, distance, distance);
var arr = def.array() as ksEntityCollection
?? throw new InvalidOperationException("array() фаски вернул не ksEntityCollection.");
arr.Add(edge);
if (!entity.Create())
throw new InvalidOperationException("Create() фаски вернул FALSE (катет слишком велик для ребра?).");
var id = _nextId++;
_features[id] = entity;
return id;
}, ct);
/// <summary>Перестроить документ.</summary>
public Task RebuildAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
@@ -271,6 +329,19 @@ public sealed 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)
{
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}) не найдено ребра. Сделайте снимок и сверьте координаты точки на ребре.");
return edges.GetByIndex(0) as ksEntity
?? throw new InvalidOperationException("Ребро из коллекции не приводится к ksEntity.");
}
private SketchEntry RequireSketch(int id)
=> _sketches.TryGetValue(id, out var s) ? s
: throw new KeyNotFoundException($"Эскиз с id={id} не найден.");
+18
View File
@@ -55,6 +55,24 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler)
return $"Вырез вращением (cut) создан, id={id}.";
}
[McpServerTool(Name = "fillet_edge")]
[Description("Скруглить ребро детали, выбранное по точке (мировые координаты x,y,z в мм, точка должна лежать на ребре), радиусом radius (мм). Сделайте model_snapshot, прикиньте точку на ребре. Возвращает id операции.")]
public async Task<string> FilletEdge(double x, double y, double z, double radius)
{
await session.ConnectAsync();
var id = await modeler.FilletEdgeAsync(x, y, z, radius);
return $"Скругление R{radius} создано на ребре в точке ({x}, {y}, {z}), id={id}.";
}
[McpServerTool(Name = "chamfer_edge")]
[Description("Снять фаску с ребра детали, выбранного по точке (мировые координаты x,y,z в мм, точка должна лежать на ребре), катетом distance (мм). Сделайте model_snapshot, прикиньте точку на ребре. Возвращает id операции.")]
public async Task<string> ChamferEdge(double x, double y, double z, double distance)
{
await session.ConnectAsync();
var id = await modeler.ChamferEdgeAsync(x, y, z, distance);
return $"Фаска {distance}×{distance} создана на ребре в точке ({x}, {y}, {z}), id={id}.";
}
[McpServerTool(Name = "rebuild")]
[Description("Перестроить активный документ.")]
public async Task<string> Rebuild()
@@ -120,6 +120,41 @@ public sealed class ModelingTests
}
}
[Fact]
public async Task Fillet_and_chamfer_edges()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Цилиндр R10×H20 → верхнее ребро на (10,0,20), нижнее на (10,0,0).
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddCircleAsync(s, 0, 0, 10);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 20);
var cylinderVolume = (await _query.GetPartInfoAsync()).Volume;
// Скругление верхнего ребра R3 и фаска нижнего 2×2.
var fil = await _modeler.FilletEdgeAsync(10, 0, 20, 3);
Assert.True(fil > 0);
var cha = await _modeler.ChamferEdgeAsync(10, 0, 0, 2);
Assert.True(cha > 0);
await _modeler.RebuildAsync();
var path = TestPaths.NewFile("_fillet.png");
var snap = await _snap.CaptureActiveModelAsync(saveToFile: path);
Assert.True(snap.Bytes.Length > 1000);
// Скругление и фаска снимают материал с рёбер → объём уменьшается, но незначительно.
var after = (await _query.GetPartInfoAsync()).Volume;
Assert.True(after < cylinderVolume, "После скругления/фаски объём должен уменьшиться.");
Assert.InRange(after, cylinderVolume * 0.9, cylinderVolume);
}
finally
{
await _docs.CloseAsync(save: false);
}
}
[Fact]
public async Task Reset_invalidates_sketch_ids()
{