feat(query): перечисление граней + выбор грани по индексу

QueryService.ListFacesAsync: обход EntityCollection(o3d_face), для каждой грани —
индекс, тип поверхности (plane/cylinder/cone/sphere/torus/nurbs через ksFaceDefinition.Is*)
и площадь (GetArea, мм²). Запись FaceInfo.
PartModeler.OpenSketchOnFaceIndexAsync: эскиз на грани по индексу из list_faces —
надёжнее выбора по точке (агенту не нужно угадывать координаты).
Новые инструменты: list_faces, sketch_create_on_face_index.

Тест List_faces_and_sketch_by_index: цилиндр → 3 грани (2 plane + 1 cylinder),
площади торца ≈314 мм² и бока ≈1257 мм²; эскиз на торце по индексу → бобышка.
Презентация: 28 инструментов, 18 тестов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@
This commit is contained in:
2026-05-26 10:08:20 +03:00
parent 8c778a449d
commit 8ac92095b6
7 changed files with 149 additions and 8 deletions
+8 -8
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 (26 инструментов)</span>
<span class="badge"><span class="dot" style="background:var(--green);box-shadow:0 0 10px var(--green)"></span> Статус: v1 работает — цикл эскиз→операции→снимок через MCP (28 инструментов)</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>v2: грань · вращение · скругление/фаска · 17 тестов зелёные</span></div>
<div class="bar"><i style="width:80%"></i></div>
<div class="progrow"><span>Общий прогресс</span><span>v2: грань · вращение · скругление/фаска · габариты · список граней · 18 тестов</span></div>
<div class="bar"><i style="width:82%"></i></div>
</div>
<div class="todo-grid" style="margin-top:26px">
@@ -282,15 +282,15 @@
<!-- TOOLS -->
<div class="panel">
<h3>Инструменты v1 <span class="tag done">ГОТОВО</span></h3>
<p class="meta">26 инструментов, отдаются по MCP-протоколу</p>
<p class="meta">28 инструментов, отдаются по 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>Sketch</code>: 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 · 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 · get_bounding_box 🆕</span></li>
<li><span class="mark todo"></span><span><code>Query</code>: list_bodies · list_faces (далее)</span></li>
<li class="muted"><span class="mark ok"></span><span class="t"><code>Query</code>: get_part_info · get_bounding_box · list_faces 🆕</span></li>
<li><span class="mark todo"></span><span><code>Query</code>: list_bodies · list_edges (далее)</span></li>
</ul>
</div>
@@ -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>
@@ -71,6 +71,25 @@ public sealed class PartModeler : IDisposable
return CreateSketchOn(part, face);
}, ct);
/// <summary>
/// Создать эскиз на грани детали, заданной индексом из list_faces, и открыть на редактирование.
/// Индекс стабилен, пока геометрия не меняется. Возвращает id эскиза.
/// </summary>
public Task<int> OpenSketchOnFaceIndexAsync(int faceIndex, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var faces = part.EntityCollection((short)Obj3dType.o3d_face) as ksEntityCollection
?? throw new InvalidOperationException("Не удалось получить коллекцию граней.");
if (faceIndex < 0 || faceIndex >= faces.GetCount())
throw new ArgumentOutOfRangeException(nameof(faceIndex),
$"Индекс грани вне диапазона [0; {faces.GetCount() - 1}]. Сверьтесь с list_faces.");
var face = faces.GetByIndex(faceIndex) as ksEntity
?? throw new InvalidOperationException("Грань по индексу не приводится к ksEntity.");
return CreateSketchOn(part, face);
}, ct);
/// <summary>Общее ядро: создать эскиз на заданной плоскости/грани и открыть на редактирование.</summary>
private int CreateSketchOn(ksPart part, ksEntity planeEntity)
{
+14
View File
@@ -0,0 +1,14 @@
namespace Kompas.Mcp.Core.Query;
/// <summary>Сводка по грани детали для выбора в итеративном цикле построения.</summary>
public sealed record FaceInfo
{
/// <summary>Индекс грани в коллекции (стабилен, пока геометрия не меняется).</summary>
public required int Index { get; init; }
/// <summary>Тип поверхности: plane, cylinder, cone, sphere, torus, nurbs, other.</summary>
public required string Type { get; init; }
/// <summary>Площадь грани, мм².</summary>
public required double Area { get; init; }
}
+48
View File
@@ -61,6 +61,54 @@ public sealed class QueryService
}
}, ct);
// ST_MIX_MM (0x1) — площади граней в мм².
private const uint MixMm = 0x1;
/// <summary>Перечислить грани активной детали: индекс, тип поверхности и площадь (мм²).</summary>
public Task<IReadOnlyList<FaceInfo>> ListFacesAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync<IReadOnlyList<FaceInfo>>(() =>
{
var doc3d = _session.Kompas.ActiveDocument3D() as ksDocument3D
?? throw new InvalidOperationException("Нет активного 3D-документа.");
var part = doc3d.GetPart((short)Part_Type.pTop_Part) as ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
try
{
var faces = part.EntityCollection((short)Obj3dType.o3d_face) as ksEntityCollection
?? throw new InvalidOperationException("Не удалось получить коллекцию граней.");
var count = faces.GetCount();
var list = new List<FaceInfo>(count);
for (int i = 0; i < count; i++)
{
if (faces.GetByIndex(i) is not ksEntity entity) continue;
if (entity.GetDefinition() is not ksFaceDefinition def) continue;
list.Add(new FaceInfo
{
Index = i,
Type = FaceType(def),
Area = def.GetArea(MixMm),
});
}
return list;
}
finally
{
if (Marshal.IsComObject(part)) Marshal.ReleaseComObject(part);
}
}, ct);
private static string FaceType(ksFaceDefinition def)
{
if (def.IsPlanar()) return "plane";
if (def.IsCylinder()) return "cylinder";
if (def.IsCone()) return "cone";
if (def.IsSphere()) return "sphere";
if (def.IsTorus()) return "torus";
if (def.IsNurbsSurface()) return "nurbs";
return "other";
}
/// <summary>Габаритный параллелепипед активной детали (по телам), мм.</summary>
public Task<BoundingBox> GetBoundingBoxAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
+11
View File
@@ -19,6 +19,17 @@ public sealed class QueryTools(KompasSession session, QueryService query)
$"центр масс: ({i.CenterX:F2}, {i.CenterY:F2}, {i.CenterZ:F2}).";
}
[McpServerTool(Name = "list_faces")]
[Description("Перечислить грани активной детали: индекс, тип поверхности (plane/cylinder/cone/sphere/torus/nurbs) и площадь (мм²). Индекс используйте в sketch_create_on_face_index. Удобно для надёжного выбора грани без угадывания координат.")]
public async Task<string> ListFaces()
{
await session.ConnectAsync();
var faces = await query.ListFacesAsync();
if (faces.Count == 0) return "Граней нет (модель пуста?).";
var lines = faces.Select(f => $" [{f.Index}] {f.Type}, площадь {f.Area:F2} мм²");
return $"Граней: {faces.Count}\n" + string.Join("\n", lines);
}
[McpServerTool(Name = "get_bounding_box")]
[Description("Габариты активной детали: координаты габаритного параллелепипеда (мм) и его размеры. Помогает оценить протяжённость модели и подобрать точки для выбора граней/рёбер.")]
public async Task<string> GetBoundingBox()
+9
View File
@@ -45,6 +45,15 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
return "Отрезок добавлен.";
}
[McpServerTool(Name = "sketch_create_on_face_index")]
[Description("Создать эскиз на грани детали, заданной индексом из list_faces, и открыть на редактирование. Надёжнее выбора по точке (не нужно угадывать координаты). Возвращает id эскиза.")]
public async Task<string> CreateOnFaceIndex([Description("Индекс грани из list_faces")] int faceIndex)
{
await session.ConnectAsync();
var id = await modeler.OpenSketchOnFaceIndexAsync(faceIndex);
return $"Эскиз создан на грани [{faceIndex}], id={id}.";
}
[McpServerTool(Name = "sketch_add_axis")]
[Description("Добавить осевую линию (ось вращения) в открытый эскиз — нужна для revolve_boss/revolve_cut. Две точки в плоскости эскиза, мм.")]
public async Task<string> AddAxis(int sketchId, double x1, double y1, double x2, double y2)
@@ -155,6 +155,46 @@ public sealed class ModelingTests
}
}
[Fact]
public async Task List_faces_and_sketch_by_index()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Цилиндр R10×H20: 3 грани — 2 плоские (торцы) + 1 цилиндрическая (бок).
var s1 = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddCircleAsync(s1, 0, 0, 10);
await _modeler.CloseSketchAsync(s1);
await _modeler.ExtrudeAsync(s1, depth: 20);
var faces = await _query.ListFacesAsync();
Assert.Equal(3, faces.Count);
Assert.Equal(2, faces.Count(f => f.Type == "plane"));
Assert.Equal(1, faces.Count(f => f.Type == "cylinder"));
// Площадь торца ≈ π·10² ≈ 314 мм²; боковой ≈ 2π·10·20 ≈ 1257 мм².
var planar = faces.First(f => f.Type == "plane");
Assert.InRange(planar.Area, Math.PI * 100 * 0.95, Math.PI * 100 * 1.05);
var side = faces.First(f => f.Type == "cylinder");
Assert.InRange(side.Area, 2 * Math.PI * 10 * 20 * 0.95, 2 * Math.PI * 10 * 20 * 1.05);
// Эскиз на плоской грани по индексу → бобышка (проверяем, что выбор по индексу работает).
var s2 = await _modeler.OpenSketchOnFaceIndexAsync(planar.Index);
await _modeler.AddCircleAsync(s2, 0, 0, 4);
await _modeler.CloseSketchAsync(s2);
var boss = await _modeler.ExtrudeAsync(s2, depth: 8, forward: false);
Assert.True(boss > 0);
await _modeler.RebuildAsync();
// Модель осталась валидной и опрашиваемой.
Assert.True((await _query.GetPartInfoAsync()).Volume > 0);
}
finally
{
await _docs.CloseAsync(save: false);
}
}
[Fact]
public async Task Reset_invalidates_sketch_ids()
{