From 13ab0f65816e58e7e2e17c67ecfe9a643d38fe10 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Wed, 27 May 2026 09:53:54 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BE=D1=82=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=B5=20(hole)=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?API7=20=E2=80=94=20IHoles3D/IHole3D=20+=20IHoleDisposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HoleService (Core/Modeling): простое цилиндрическое отверстие (ksHTBase), сквозное/глухое - размещение: BaseSurface=грань (FindObjectsByPoint) + AssociationVertex=Points3D.Add() (точка по мировым координатам ksPParamCoord+X/Y/Z); эскиз размещения не нужен - в API5 определения отверстия НЕТ (как draft) — реализовано на API7 (прецедент: move_face) - авто-направление в тело с повтором; DI-регистрация; инструмент hole в FeatureTools - интеграционные тесты: сквозное (Ø10 удаляет π·25·20) + глухое (Ø8×10); spike зелёный с первого раза - 103 теста зелёных; спек docs/superpowers/specs/2026-05-27-hole-design.md --- .../specs/2026-05-27-hole-design.md | 79 ++++++++++++++ src/Kompas.Mcp.Core/Modeling/HoleService.cs | 100 ++++++++++++++++++ src/Kompas.Mcp.Host/Program.cs | 1 + src/Kompas.Mcp.Host/Tools/FeatureTools.cs | 16 ++- .../Kompas.Mcp.Tests/Integration/HoleTests.cs | 74 +++++++++++++ 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-05-27-hole-design.md create mode 100644 src/Kompas.Mcp.Core/Modeling/HoleService.cs create mode 100644 tests/Kompas.Mcp.Tests/Integration/HoleTests.cs diff --git a/docs/superpowers/specs/2026-05-27-hole-design.md b/docs/superpowers/specs/2026-05-27-hole-design.md new file mode 100644 index 0000000..2740def --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-hole-design.md @@ -0,0 +1,79 @@ +# Дизайн: отверстие (hole) через API7 + +**Дата:** 2026-05-27 +**Статус:** реализовано и проверено (spike зелёный); на ревью (ревьюер — Codex) + +## Цель + +Добавить параметрическую операцию **«Отверстие»** (простое цилиндрическое, ksHTBase) — сквозное +или глухое, размещённое на грани по мировой точке. Приоритет 2 из плана. + +## Ключевой факт: отверстие — только API7 + +В **API5 определения отверстия НЕТ** (по рефлексии `Kompas6API5.dll` нет ни одного типа с `Hole`; +есть лишь `Obj3dType.o3d_holeOperation=52` без интерфейса — как было с draft). В **API7** — +богатая поддержка: `IModelContainer.Holes3D` (`IHoles3D`) → `Add()` → `IHole3D`, размещение через +`IHoleDisposal`. Прецедент API7 в слое моделирования уже есть — `move_face` (`FaceEditService`). +Поэтому отдельный сервис `HoleService` (API7), а не метод `PartModeler` (API5). + +## Подтверждённый workflow (spike зелёный) + +``` +IPart7 top = (IPart7)doc3d.TopPart; +IFace face = top.FindObjectsByPoint(x,y,z,true) → первый IFace; // как в move_face +IModelContainer c = (IModelContainer)top; // COM-QI + +// Точка размещения центра отверстия в МИРОВЫХ координатах: +IPoint3D pt = c.Points3D.Add(); +pt.ParameterType = ksPParamCoord; pt.X=x; pt.Y=y; pt.Z=z; pt.Update(); + +IHole3D hole = c.Holes3D.Add(); +hole.HoleType = ksHTBase; // простое цилиндрическое +hole.Diameter = diameter; // мм +hole.DepthType = throughAll ? ksDTReachThrough : ksDTValue; +if (!throughAll) hole.Depth = depth; // мм +hole.EndFaceType = ksEFFlat; hole.Axis=false; hole.ShowThread=false; + +IHoleDisposal d = (IHoleDisposal)hole; // QI +d.BaseSurface = (IModelObject)face; // обязательна грань +d.Perpendicular = true; +d.AssociationVertex = (IModelObject)pt; // центр = созданная точка +d.Direction = true; // в тело; при FALSE — пробуем обратное +hole.Update(); // повтор с Direction=false при отказе +doc3d.RebuildDocument(); +``` + +**Размещение** (главная неочевидность, решена): у `IHole3D` нет свойства позиции — оно в +`IHoleDisposal` (`BaseSurface` = грань + `AssociationVertex` = точка/вершина). Самый надёжный +способ задать центр по мировым координатам — создать `IPoint3D` (`ksPParamCoord` + X/Y/Z) и +передать его как `AssociationVertex`. Эскиз размещения НЕ требуется (в отличие от `extrude_cut`). + +Единицы: `Diameter`/`Depth`/`X`/`Y`/`Z` — мм. `EndFaceType=ksEFFlat` (плоский торец) для глухого. + +## MCP-инструмент (группа Feature) + +| Инструмент | Параметры | Поведение | +|---|---|---| +| `hole` | `x,y,z: double`, `diameter: double`, `depth=0`, `throughAll=false` | Просверлить цилиндрическое отверстие на грани, найденной по точке (x,y,z — центр). `throughAll` — сквозное (depth не нужен), иначе глухое на `depth`. Направление в тело — авто (как move_face). | + +**Решения (YAGNI):** на первом этапе только `ksHTBase` (простое цилиндрическое); зенковка/ +цековка/коническое (`ksHTCounterbore/Countersinking/Conic` + параметрические подынтерфейсы) и +резьба (`ShowThread`/`IThread`) — расширения позже. Глубина «до объекта» (`ksDTObject`) не +выставляется. Отверстие не регистрируется в реестре `_features` (он для API5-операций) — массивы/ +зеркало пакета C к нему неприменимы; контракт инструмента возвращает подтверждение без id. + +## Реализация + +- **`src/Kompas.Mcp.Core/Modeling/HoleService.cs`** (новый): `HoleAsync(x,y,z,diameter,depth,throughAll,ct)` + на STA-потоке; валидация `diameter>0`, `depth>0` (если не throughAll); `FindFaceAtPoint` + (копия паттерна из `FaceEditService`); авто-направление с повтором. +- **DI:** `AddSingleton()` в `Program.cs`. +- **Инструмент** `hole` в `FeatureTools.cs` (конструктор получает `HoleService`). +- Транзитные RCW не освобождаем точечно — консистентно (долг v2-2). + +## Тестирование (Integration, `HoleTests`) + +1. **Сквозное**: коробка 40×40×20 (центр 0,0; верх Z=20) → `hole(0,0,20, Ø10, throughAll)` → + удалён цилиндр π·25·20 ≈ 1570.8 мм³; `InRange(before-after, ±5%)`. ✓ +2. **Глухое**: та же коробка → `hole(0,0,20, Ø8, depth=10)` → удалён π·16·10 ≈ 502.7 мм³; + `InRange(before-after, ±10%)` (торец плоский, погрешность модели). ✓ diff --git a/src/Kompas.Mcp.Core/Modeling/HoleService.cs b/src/Kompas.Mcp.Core/Modeling/HoleService.cs new file mode 100644 index 0000000..0612496 --- /dev/null +++ b/src/Kompas.Mcp.Core/Modeling/HoleService.cs @@ -0,0 +1,100 @@ +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Threading; +using Kompas6Constants3D; +using KompasAPI7; + +namespace Kompas.Mcp.Core.Modeling; + +/// +/// Операция «Отверстие» через API7 (IHoles3D/IHole3D). В API5 определения +/// отверстия нет, поэтому используется современный API7 (как и прямое редактирование грани). +/// +[SupportedOSPlatform("windows")] +public sealed class HoleService +{ + private readonly KompasSession _session; + private readonly KompasDispatcher _dispatcher; + + public HoleService(KompasSession session, KompasDispatcher dispatcher) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + /// + /// Просверлить простое цилиндрическое отверстие (ksHTBase) на грани, найденной по мировой точке + /// (,,), с центром в этой точке. + /// diameter — диаметр (мм). throughAll — сквозное (depth игнорируется), иначе на глубину depth (мм). + /// + 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), ct); + + private void HoleCore(double x, double y, double z, double diameter, double depth, bool throughAll) + { + if (!(diameter > 0)) throw new ArgumentOutOfRangeException(nameof(diameter), "Диаметр должен быть > 0."); + if (!throughAll && !(depth > 0)) throw new ArgumentOutOfRangeException(nameof(depth), "Глубина должна быть > 0 (или throughAll)."); + + if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d) + throw new InvalidOperationException("Активный документ не является 3D-документом."); + if (doc3d.TopPart is not IPart7 top) + throw new InvalidOperationException("Не удалось получить TopPart активного документа."); + + var face = FindFaceAtPoint(top, x, y, z) + ?? throw new InvalidOperationException( + $"В точке ({x}, {y}, {z}) не найдена грань. Сверьте координаты точки на грани (model_snapshot)."); + + var container = top as IModelContainer + ?? throw new InvalidOperationException("Деталь не приводится к IModelContainer."); + + // Точка размещения центра отверстия в мировых координатах. + var pt = container.Points3D.Add() + ?? throw new InvalidOperationException("Points3D.Add() вернул null."); + pt.ParameterType = ksPoint3DTypeEnum.ksPParamCoord; + pt.X = x; pt.Y = y; pt.Z = z; + if (!pt.Update()) + throw new InvalidOperationException("Update() точки размещения вернул FALSE."); + + var hole = container.Holes3D.Add() + ?? throw new InvalidOperationException("Holes3D.Add() вернул null."); + hole.HoleType = ksHoleTypeEnum.ksHTBase; + hole.Diameter = diameter; + hole.DepthType = throughAll ? ksDepthTypeEnum.ksDTReachThrough : ksDepthTypeEnum.ksDTValue; + if (!throughAll) hole.Depth = depth; + hole.EndFaceType = ksEndFaceTypeEnum.ksEFFlat; + hole.Axis = false; + hole.ShowThread = false; + + var disp = (IHoleDisposal)hole; + disp.BaseSurface = (IModelObject)face; + disp.Perpendicular = true; + disp.AssociationVertex = (IModelObject)pt; + disp.Direction = true; + + if (!hole.Update()) + { + disp.Direction = false; + if (!hole.Update()) + throw new InvalidOperationException("Update() отверстия вернул FALSE для обоих направлений."); + } + + doc3d.RebuildDocument(); + } + + private static IFace? FindFaceAtPoint(IPart7 part, double x, double y, double z) + { + var hit = part.FindObjectsByPoint(x, y, z, true); + switch (hit) + { + case IFace f: + return f; + case object[] arr: + foreach (var o in arr) if (o is IFace face) return face; + return null; + case System.Collections.IEnumerable en: + foreach (var o in en) if (o is IFace face) return face; + return null; + default: + return null; + } + } +} diff --git a/src/Kompas.Mcp.Host/Program.cs b/src/Kompas.Mcp.Host/Program.cs index c5fbbe3..83be864 100644 --- a/src/Kompas.Mcp.Host/Program.cs +++ b/src/Kompas.Mcp.Host/Program.cs @@ -22,6 +22,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs index 654b0a2..004c874 100644 --- a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs +++ b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs @@ -7,7 +7,7 @@ namespace Kompas.Mcp.Host.Tools; /// Инструменты формообразующих операций. [McpServerToolType] -public sealed class FeatureTools(KompasSession session, PartModeler modeler) +public sealed class FeatureTools(KompasSession session, PartModeler modeler, HoleService holes) { [McpServerTool(Name = "extrude_boss")] [Description("Выдавить эскиз, добавив материал (на глубину depth, мм). Возвращает id операции.")] @@ -184,6 +184,20 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler) return $"Тело зеркально отражено (плоскость {plane}), id={id}."; } + [McpServerTool(Name = "hole")] + [Description("Просверлить простое цилиндрическое отверстие (API7) на грани, выбранной по мировой точке (x,y,z в мм — точка должна лежать на грани и задаёт ЦЕНТР отверстия; сделайте model_snapshot/describe_face и прикиньте точку). diameter — диаметр (мм). throughAll=true — сквозное (depth не нужен); иначе глухое на глубину depth (мм). Направление в тело подбирается автоматически. Отличие от extrude_cut: параметрическое отверстие без эскиза.")] + public async Task Hole( + double x, double y, double z, + [Description("Диаметр отверстия, мм")] double diameter, + [Description("Глубина, мм (нужна, если throughAll=false)")] double depth = 0, + [Description("Сквозное отверстие через всё тело")] bool throughAll = false) + { + await session.ConnectAsync(); + await holes.HoleAsync(x, y, z, diameter, depth, throughAll); + var kind = throughAll ? "сквозное" : $"глухое на {depth} мм"; + return $"Отверстие Ø{diameter} ({kind}) создано на грани в точке ({x}, {y}, {z})."; + } + [McpServerTool(Name = "rebuild")] [Description("Перестроить активный документ.")] public async Task Rebuild() diff --git a/tests/Kompas.Mcp.Tests/Integration/HoleTests.cs b/tests/Kompas.Mcp.Tests/Integration/HoleTests.cs new file mode 100644 index 0000000..88d4a9e --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/HoleTests.cs @@ -0,0 +1,74 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Query; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: операция «Отверстие» (API7 IHoles3D/IHole3D). +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class HoleTests +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly HoleService _holes; + private readonly QueryService _query; + + public HoleTests(KompasFixture fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _modeler = new PartModeler(fx.Session, fx.Dispatcher); + _holes = new HoleService(fx.Session, fx.Dispatcher); + _query = new QueryService(fx.Session, fx.Dispatcher); + } + + [Fact] + public async Task Hole_through_top_face_removes_cylinder_volume() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Коробка 40×40×20 (центр в начале координат), верхняя грань на Z=20. + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, -20, -20, 20, 20); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: 20); + var before = (await _query.GetPartInfoAsync()).Volume; // 32000 + + // Сквозное отверстие Ø10 по центру верхней грани (0,0,20). + await _holes.HoleAsync(0, 0, 20, diameter: 10, depth: 0, throughAll: true); + var after = (await _query.GetPartInfoAsync()).Volume; + + // Удалён цилиндр π·25·20 ≈ 1570.8 мм³. + var removed = Math.PI * 25 * 20; + Assert.True(after < before, "После отверстия объём должен уменьшиться."); + Assert.InRange(before - after, removed * 0.95, removed * 1.05); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Hole_blind_in_top_face_removes_partial_cylinder() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Коробка 40×40×20, верхняя грань на Z=20. + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, -20, -20, 20, 20); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: 20); + var before = (await _query.GetPartInfoAsync()).Volume; // 32000 + + // Глухое отверстие Ø8 глубиной 10 по центру верхней грани. + await _holes.HoleAsync(0, 0, 20, diameter: 8, depth: 10, throughAll: false); + var after = (await _query.GetPartInfoAsync()).Volume; + + // Удалён цилиндр π·16·10 ≈ 502.7 мм³ (без учёта плоского торца — EndFaceType=Flat). + var removed = Math.PI * 16 * 10; + Assert.True(after < before, "После глухого отверстия объём должен уменьшиться."); + Assert.InRange(before - after, removed * 0.9, removed * 1.1); + } + finally { await _docs.CloseAsync(save: false); } + } +}