diff --git a/CLAUDE.md b/CLAUDE.md index 6f65fb2..eab37b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,9 @@ The index lives at `docs/kompas_sdk_index.tsv` (`filenametitleexcerpt` **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) diff --git a/README.md b/README.md index bfd25db..694e123 100644 --- a/README.md +++ b/README.md @@ -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` | ### Типовой сценарий diff --git a/docs/presentation.html b/docs/presentation.html index 096b53b..9d51ab9 100644 --- a/docs/presentation.html +++ b/docs/presentation.html @@ -125,7 +125,7 @@
- Статус: v1 работает — цикл эскиз→операции→снимок через MCP (28 инструментов) + Статус: v2 работает — цикл эскиз→операции→снимок через MCP (31 инструмент)

КОМПАС-3D MCP-сервер
управление CAD языком LLM

MCP-сервер, который превращает операции КОМПАС-3D — создание документов, эскизы, 3D-операции, параметры — в инструменты для языковой модели. Под капотом — @@ -246,8 +246,8 @@ впереди.

-
Общий прогрессv2: грань · вращение · скругление/фаска · габариты · список граней · 18 тестов
-
+
Общий прогрессv2: грань · вращение · скругление/фаска · габариты · список граней/рёбер · 19 тестов
+
@@ -282,15 +282,15 @@

Инструменты v1 ГОТОВО

-

28 инструментов, отдаются по MCP-протоколу

+

31 инструмент, отдаются по MCP-протоколу

  • System: connect · status · set_visible
  • Documents: create · open · save · save_as · close · active
  • Sketch: create · on_face (точка/индекс) · add line/circle/rectangle/axis · close
  • -
  • Features: extrude · revolve · fillet_edge/chamfer_edge 🆕 · rebuild
  • +
  • Features: extrude · revolve · fillet_edge/chamfer_edge (точка) · fillet_edge_index/chamfer_edge_index (индекс) 🆕 · rebuild
  • Vision: model_snapshot 👁️
  • -
  • Query: get_part_info · get_bounding_box · list_faces 🆕
  • -
  • Query: list_bodies · list_edges (далее)
  • +
  • Query: get_part_info · get_bounding_box · list_faces · list_edges 🆕
  • +
  • Query: list_bodies (далее)
@@ -319,7 +319,7 @@ черчению и сборкам.

v1 · готово ✓

Цикл моделирования

Документы, эскизы, выдавливание/вырез, снимок модели — работает

-
v2 · в работе

Операции и выборка

Эскиз на грани ✓ · вращение ✓ · скругление/фаска ✓ · список граней ✓ · далее: массивы

+
v2 · в работе

Операции и выборка

Эскиз на грани ✓ · вращение ✓ · скругление/фаска (точка + индекс) ✓ · список граней/рёбер ✓ · далее: массивы · list_bodies

v3

Параметрика

Переменные IVariable7, свойства IPropertyMng, формулы

v4

2D-черчение

Виды, размеры, тексты, штриховки; спецификации

v5

Сборки и экспорт

Компоненты, сопряжения, конвертеры (STEP), HTTP/SSE-транспорт

diff --git a/src/Kompas.Mcp.Core/Modeling/PartModeler.cs b/src/Kompas.Mcp.Core/Modeling/PartModeler.cs index b66c2ea..bf769f6 100644 --- a/src/Kompas.Mcp.Core/Modeling/PartModeler.cs +++ b/src/Kompas.Mcp.Core/Modeling/PartModeler.cs @@ -272,28 +272,40 @@ public sealed class PartModeler : IDisposable public Task 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); + /// Скруглить ребро детали по индексу из list_edges радиусом . + public Task 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; + } + /// /// Снять фаску с ребра детали, найденного по точке (мировые координаты, мм), катетом . /// Возвращает id операции. @@ -301,28 +313,40 @@ public sealed class PartModeler : IDisposable public Task 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); + /// Снять фаску с ребра детали по индексу из list_edges катетом . + public Task 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; + } + /// Перестроить документ. public Task RebuildAsync(CancellationToken ct = default) => _dispatcher.InvokeAsync(() => @@ -361,6 +385,18 @@ public sealed class PartModeler : IDisposable ?? throw new InvalidOperationException("Ребро из коллекции не приводится к ksEntity."); } + /// Выбрать ребро детали по индексу из list_edges. + 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} не найден."); diff --git a/src/Kompas.Mcp.Core/Query/EdgeInfo.cs b/src/Kompas.Mcp.Core/Query/EdgeInfo.cs new file mode 100644 index 0000000..a255108 --- /dev/null +++ b/src/Kompas.Mcp.Core/Query/EdgeInfo.cs @@ -0,0 +1,14 @@ +namespace Kompas.Mcp.Core.Query; + +/// Сводка по ребру детали для выбора (скругление/фаска) в цикле построения. +public sealed record EdgeInfo +{ + /// Индекс ребра в коллекции (стабилен, пока геометрия не меняется). + public required int Index { get; init; } + + /// Тип кривой ребра: line, circle, arc, ellipse, ellipse_arc, nurbs, other. + public required string Type { get; init; } + + /// Длина ребра, мм. + public required double Length { get; init; } +} diff --git a/src/Kompas.Mcp.Core/Query/QueryService.cs b/src/Kompas.Mcp.Core/Query/QueryService.cs index 3f7272f..b455c62 100644 --- a/src/Kompas.Mcp.Core/Query/QueryService.cs +++ b/src/Kompas.Mcp.Core/Query/QueryService.cs @@ -109,6 +109,51 @@ public sealed class QueryService return "other"; } + /// Перечислить рёбра активной детали: индекс, тип кривой и длину (мм). + public Task> ListEdgesAsync(CancellationToken ct = default) + => _dispatcher.InvokeAsync>(() => + { + 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(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"; + } + /// Габаритный параллелепипед активной детали (по телам), мм. public Task GetBoundingBoxAsync(CancellationToken ct = default) => _dispatcher.InvokeAsync(() => diff --git a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs index 5f572e4..0dfa09f 100644 --- a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs +++ b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs @@ -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 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 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 Rebuild() diff --git a/src/Kompas.Mcp.Host/Tools/QueryTools.cs b/src/Kompas.Mcp.Host/Tools/QueryTools.cs index 121ac7c..bfb0146 100644 --- a/src/Kompas.Mcp.Host/Tools/QueryTools.cs +++ b/src/Kompas.Mcp.Host/Tools/QueryTools.cs @@ -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 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 GetBoundingBox() diff --git a/tests/Kompas.Mcp.Tests/Integration/ModelingTests.cs b/tests/Kompas.Mcp.Tests/Integration/ModelingTests.cs index 4c35661..4f5d3b4 100644 --- a/tests/Kompas.Mcp.Tests/Integration/ModelingTests.cs +++ b/tests/Kompas.Mcp.Tests/Integration/ModelingTests.cs @@ -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() {