feat(query): перечисление рёбер + скругление/фаска по индексу

QueryService.ListEdgesAsync: обход EntityCollection(o3d_edge), для каждого ребра —
индекс, тип кривой (line/circle/arc/ellipse/nurbs через ksEdgeDefinition.Is*) и длина
(GetLength, мм). Запись EdgeInfo. Новый инструмент list_edges.
PartModeler: выделены ядра CreateFillet/CreateChamfer и хелпер SelectEdgeByIndex;
добавлены FilletEdgeIndexAsync/ChamferEdgeIndexAsync. Новые инструменты
fillet_edge_index, chamfer_edge_index — выбор ребра по индексу (надёжнее точки).

Тест List_edges_and_fillet_by_index: цилиндр → 2 круговых ребра ≈62.83 мм →
скругление ребра по индексу → объём уменьшается. Итого 31 инструмент, 19 тестов.
Документация синхронизирована через навык docs-delegate (Sonnet).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@
This commit is contained in:
2026-05-26 11:22:57 +03:00
parent 170e2aa4c0
commit 498eeaa847
9 changed files with 211 additions and 52 deletions
+5 -5
View File
@@ -33,9 +33,9 @@ The index lives at `docs/kompas_sdk_index.tsv` (`filename<TAB>title<TAB>excerpt`
**v1 + v2 implemented and working** (full sketch→feature→inspect loop validated end-to-end). Stack:
**.NET 8 (`net8.0-windows`, x64), C#**, MCP via the official `ModelContextProtocol` SDK over **stdio**.
**28 MCP tools, 18 tests green.** v2 added: sketch-on-face (by point and by index), revolve (boss/cut +
sketch axis), fillet/chamfer (edge-by-point), and Query tools `get_part_info` (МЦХ), `get_bounding_box`,
`list_faces`.
**31 MCP tools, 19 tests green.** v2 added: sketch-on-face (by point and by index), revolve (boss/cut +
sketch axis), fillet/chamfer (edge-by-point and by index), and Query tools `get_part_info` (МЦХ),
`get_bounding_box`, `list_faces`, `list_edges`.
See [`README.md`](README.md) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); design decisions/caveats in
[`docs/OPEN_QUESTIONS.md`](docs/OPEN_QUESTIONS.md).
@@ -54,11 +54,11 @@ Key implementation facts (don't relearn):
- **All COM calls run on one dedicated STA thread** (`KompasDispatcher.InvokeAsync`); КОМПАС is single-instance STA. Services never touch COM off that thread.
- Connection goes through **API5** (`KOMPAS.Application.5``KompasObject`) then `ksGetApplication7()``IApplication`; the API5 root is needed for `ksPart` 3D-building and `ksDocument3D` snapshots.
- 3D is built via **API5 `ksPart`** (`NewEntity(o3d_sketch/o3d_bossExtrusion/o3d_bossRotated/o3d_fillet/o3d_chamfer/...)`, `ksBossExtrusionDefinition`/`ksCutExtrusionDefinition`/`ksBossRotatedDefinition`/`ksFilletDefinition`/`ksChamferDefinition`); cut-through holes use `dtBoth` + `etThroughAll`. API7 is used for app/documents.
- **Face/edge selection**: `ksPart.EntityCollection(o3d_face|o3d_edge)``SelectByPoint(x,y,z)` (world mm) filters to objects through the point, then `GetByIndex(0)`; or by stable index from `list_faces`. `ksFaceDefinition.IsPlanar/IsCylinder/...` + `GetArea(ST_MIX_MM)` classify faces. Sketch axis = line with system style **3** (осевая).
- **Face/edge selection**: `ksPart.EntityCollection(o3d_face|o3d_edge)``SelectByPoint(x,y,z)` (world mm) filters to objects through the point, then `GetByIndex(0)`; or by stable index from `list_faces`/`list_edges`. `ksFaceDefinition.IsPlanar/IsCylinder/...` + `GetArea(ST_MIX_MM)` classify faces. `ksEdgeDefinition.IsLineSeg/IsCircle/IsArc/...` + `GetLength()` classify/measure edges. Sketch axis = line with system style **3** (осевая). Fillet/chamfer by edge index via `fillet_edge_index`/`chamfer_edge_index` (more reliable than by-point).
- **МЦХ / mass props**: use **API5 `ksPart.CalcMassInertiaProperties(ST_MIX_MM|ST_MIX_KG)`**`ksMassInertiaParam` (`v`/`m`/`F`/`xc`/`yc`/`zc`), computed on demand. **Do NOT** use API7 `IMassInertiaParam7.Calculate()` — its `Actual` flag sticks after the first calc and won't refresh on API5 geometry changes (stale volume). Bounding box: `ksPart.GetGabarit(full:false, ...)`.
- **Snapshot**: `ksDocument3D.SaveAsToRasterFormat(file, ksRasterFormatParam)` renders to a temp file (the in-memory `resultArrayBytes` stays empty when a filename is given); read bytes back. Return to MCP via `ImageContentBlock.FromBytes(bytes, mime)`**not** `Data = bytes` (Data holds base64 bytes).
- Logs go to **stderr** (stdout is the MCP channel). Integration tests reuse one КОМПАС via `KompasFixture`; artifacts land in gitignored `.scratch/`.
- Not yet done (next): patterns/arrays, `list_bodies`/`list_edges`, variables/properties (`IVariable7`), 2D drawing, assemblies, a proper in-process MCP-client test. Known caveat: boss/cut direction on a selected face depends on face-normal orientation (`forward` may need flipping — agent picks via snapshot). See `docs/OPEN_QUESTIONS.md` → «Ревью v2».
- Not yet done (next): patterns/arrays, `list_bodies`, variables/properties (`IVariable7`), 2D drawing, assemblies, a proper in-process MCP-client test. Known caveat: boss/cut direction on a selected face depends on face-normal orientation (`forward` may need flipping — agent picks via snapshot). See `docs/OPEN_QUESTIONS.md` → «Ревью v2».
## КОМПАС-3D API architecture (the critical context)
+2 -1
View File
@@ -41,8 +41,9 @@ src/Kompas.Mcp.Host/bin/Release/net8.0-windows/kompas-mcp.exe
| System | `kompas_connect`, `kompas_status`, `kompas_set_visible` |
| Documents | `document_create`, `document_open`, `document_save`, `document_save_as`, `document_close`, `document_active` |
| Sketch | `sketch_create`, `sketch_add_line`, `sketch_add_circle`, `sketch_add_rectangle`, `sketch_close` |
| Features | `extrude_boss`, `extrude_cut`, `rebuild` |
| Features | `extrude_boss`, `extrude_cut`, `revolve_boss`, `revolve_cut`, `fillet_edge`, `chamfer_edge`, `fillet_edge_index`, `chamfer_edge_index`, `rebuild` |
| Vision | `model_snapshot` (PNG-снимок активной модели) |
| Query | `get_part_info`, `get_bounding_box`, `list_faces`, `list_edges` |
### Типовой сценарий
+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 (28 инструментов)</span>
<span class="badge"><span class="dot" style="background:var(--green);box-shadow:0 0 10px var(--green)"></span> Статус: v2 работает — цикл эскиз→операции→снимок через MCP (31 инструмент)</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: грань · вращение · скругление/фаска · габариты · список граней · 18 тестов</span></div>
<div class="bar"><i style="width:82%"></i></div>
<div class="progrow"><span>Общий прогресс</span><span>v2: грань · вращение · скругление/фаска · габариты · список граней/рёбер · 19 тестов</span></div>
<div class="bar"><i style="width:84%"></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">28 инструментов, отдаются по MCP-протоколу</p>
<p class="meta">31 инструмент, отдаются по 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 · 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>Features</code>: extrude · revolve · fillet_edge/chamfer_edge (точка) · fillet_edge_index/chamfer_edge_index (индекс) 🆕 · 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 · list_faces 🆕</span></li>
<li><span class="mark todo"></span><span><code>Query</code>: list_bodies · list_edges (далее)</span></li>
<li class="muted"><span class="mark ok"></span><span class="t"><code>Query</code>: get_part_info · get_bounding_box · list_faces · list_edges 🆕</span></li>
<li><span class="mark todo"></span><span><code>Query</code>: list_bodies (далее)</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>Эскиз на грани ✓ · вращение ✓ · скругление/фаска (точка + индекс) ✓ · список граней/рёбер ✓ · далее: массивы · list_bodies</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>
+74 -38
View File
@@ -272,28 +272,40 @@ public sealed class PartModeler : IDisposable
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;
return CreateFillet(part, SelectEdgeByPoint(part, x, y, z), radius);
}, ct);
/// <summary>Скруглить ребро детали по индексу из list_edges радиусом <paramref name="radius"/>.</summary>
public Task<int> FilletEdgeIndexAsync(int edgeIndex, double radius, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
return CreateFillet(part, SelectEdgeByIndex(part, edgeIndex), radius);
}, ct);
private int CreateFillet(ksPart part, ksEntity edge, double radius)
{
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 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;
}
/// <summary>
/// Снять фаску с ребра детали, найденного по точке (мировые координаты, мм), катетом <paramref name="distance"/>.
/// Возвращает id операции.
@@ -301,28 +313,40 @@ public sealed class PartModeler : IDisposable
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;
return CreateChamfer(part, SelectEdgeByPoint(part, x, y, z), distance);
}, ct);
/// <summary>Снять фаску с ребра детали по индексу из list_edges катетом <paramref name="distance"/>.</summary>
public Task<int> ChamferEdgeIndexAsync(int edgeIndex, double distance, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
return CreateChamfer(part, SelectEdgeByIndex(part, edgeIndex), distance);
}, ct);
private int CreateChamfer(ksPart part, ksEntity edge, double distance)
{
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 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;
}
/// <summary>Перестроить документ.</summary>
public Task RebuildAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
@@ -361,6 +385,18 @@ public sealed class PartModeler : IDisposable
?? throw new InvalidOperationException("Ребро из коллекции не приводится к ksEntity.");
}
/// <summary>Выбрать ребро детали по индексу из list_edges.</summary>
private static ksEntity SelectEdgeByIndex(ksPart part, int edgeIndex)
{
var edges = part.EntityCollection((short)Obj3dType.o3d_edge) as ksEntityCollection
?? throw new InvalidOperationException("Не удалось получить коллекцию рёбер.");
if (edgeIndex < 0 || edgeIndex >= edges.GetCount())
throw new ArgumentOutOfRangeException(nameof(edgeIndex),
$"Индекс ребра вне диапазона [0; {edges.GetCount() - 1}]. Сверьтесь с list_edges.");
return edges.GetByIndex(edgeIndex) as ksEntity
?? throw new InvalidOperationException("Ребро по индексу не приводится к ksEntity.");
}
private SketchEntry RequireSketch(int id)
=> _sketches.TryGetValue(id, out var s) ? s
: throw new KeyNotFoundException($"Эскиз с id={id} не найден.");
+14
View File
@@ -0,0 +1,14 @@
namespace Kompas.Mcp.Core.Query;
/// <summary>Сводка по ребру детали для выбора (скругление/фаска) в цикле построения.</summary>
public sealed record EdgeInfo
{
/// <summary>Индекс ребра в коллекции (стабилен, пока геометрия не меняется).</summary>
public required int Index { get; init; }
/// <summary>Тип кривой ребра: line, circle, arc, ellipse, ellipse_arc, nurbs, other.</summary>
public required string Type { get; init; }
/// <summary>Длина ребра, мм.</summary>
public required double Length { get; init; }
}
+45
View File
@@ -109,6 +109,51 @@ public sealed class QueryService
return "other";
}
/// <summary>Перечислить рёбра активной детали: индекс, тип кривой и длину (мм).</summary>
public Task<IReadOnlyList<EdgeInfo>> ListEdgesAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync<IReadOnlyList<EdgeInfo>>(() =>
{
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 edges = part.EntityCollection((short)Obj3dType.o3d_edge) as ksEntityCollection
?? throw new InvalidOperationException("Не удалось получить коллекцию рёбер.");
var count = edges.GetCount();
var list = new List<EdgeInfo>(count);
for (int i = 0; i < count; i++)
{
if (edges.GetByIndex(i) is not ksEntity entity) continue;
if (entity.GetDefinition() is not ksEdgeDefinition def) continue;
list.Add(new EdgeInfo
{
Index = i,
Type = EdgeType(def),
Length = def.GetLength(MixMm),
});
}
return list;
}
finally
{
if (Marshal.IsComObject(part)) Marshal.ReleaseComObject(part);
}
}, ct);
private static string EdgeType(ksEdgeDefinition def)
{
if (def.IsLineSeg()) return "line";
if (def.IsCircle()) return "circle";
if (def.IsArc()) return "arc";
if (def.IsEllipseArc()) return "ellipse_arc";
if (def.IsEllipse()) return "ellipse";
if (def.IsNurbs()) return "nurbs";
return "other";
}
/// <summary>Габаритный параллелепипед активной детали (по телам), мм.</summary>
public Task<BoundingBox> GetBoundingBoxAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
+18
View File
@@ -73,6 +73,24 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler)
return $"Фаска {distance}×{distance} создана на ребре в точке ({x}, {y}, {z}), id={id}.";
}
[McpServerTool(Name = "fillet_edge_index")]
[Description("Скруглить ребро детали по индексу из list_edges радиусом radius (мм). Надёжнее выбора по точке. Возвращает id операции.")]
public async Task<string> FilletEdgeIndex([Description("Индекс ребра из list_edges")] int edgeIndex, double radius)
{
await session.ConnectAsync();
var id = await modeler.FilletEdgeIndexAsync(edgeIndex, radius);
return $"Скругление R{radius} создано на ребре [{edgeIndex}], id={id}.";
}
[McpServerTool(Name = "chamfer_edge_index")]
[Description("Снять фаску с ребра детали по индексу из list_edges катетом distance (мм). Надёжнее выбора по точке. Возвращает id операции.")]
public async Task<string> ChamferEdgeIndex([Description("Индекс ребра из list_edges")] int edgeIndex, double distance)
{
await session.ConnectAsync();
var id = await modeler.ChamferEdgeIndexAsync(edgeIndex, distance);
return $"Фаска {distance}×{distance} создана на ребре [{edgeIndex}], id={id}.";
}
[McpServerTool(Name = "rebuild")]
[Description("Перестроить активный документ.")]
public async Task<string> Rebuild()
+11
View File
@@ -30,6 +30,17 @@ public sealed class QueryTools(KompasSession session, QueryService query)
return $"Граней: {faces.Count}\n" + string.Join("\n", lines);
}
[McpServerTool(Name = "list_edges")]
[Description("Перечислить рёбра активной детали: индекс, тип кривой (line/circle/arc/ellipse/nurbs) и длину (мм). Индекс используйте в fillet_edge_index / chamfer_edge_index для надёжного выбора ребра без угадывания координат.")]
public async Task<string> ListEdges()
{
await session.ConnectAsync();
var edges = await query.ListEdgesAsync();
if (edges.Count == 0) return "Рёбер нет (модель пуста?).";
var lines = edges.Select(e => $" [{e.Index}] {e.Type}, длина {e.Length:F2} мм");
return $"Рёбер: {edges.Count}\n" + string.Join("\n", lines);
}
[McpServerTool(Name = "get_bounding_box")]
[Description("Габариты активной детали: координаты габаритного параллелепипеда (мм) и его размеры. Помогает оценить протяжённость модели и подобрать точки для выбора граней/рёбер.")]
public async Task<string> GetBoundingBox()
@@ -195,6 +195,40 @@ public sealed class ModelingTests
}
}
[Fact]
public async Task List_edges_and_fillet_by_index()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Цилиндр R10×H20: торцевые рёбра — две окружности длиной 2π·10 ≈ 62.83 мм.
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 edges = await _query.ListEdgesAsync();
var circles = edges.Where(e => e.Type == "circle").ToList();
Assert.True(circles.Count >= 2, "У цилиндра ожидались минимум два круговых ребра (торцы).");
foreach (var c in circles)
Assert.InRange(c.Length, 2 * Math.PI * 10 * 0.95, 2 * Math.PI * 10 * 1.05);
// Скругление кругового ребра по индексу → объём уменьшается.
var before = (await _query.GetPartInfoAsync()).Volume;
var fil = await _modeler.FilletEdgeIndexAsync(circles[0].Index, 3);
Assert.True(fil > 0);
await _modeler.RebuildAsync();
var after = (await _query.GetPartInfoAsync()).Volume;
Assert.True(after < before, "После скругления ребра объём должен уменьшиться.");
Assert.InRange(after, before * 0.9, before);
}
finally
{
await _docs.CloseAsync(save: false);
}
}
[Fact]
public async Task Reset_invalidates_sketch_ids()
{