feat: отверстие (hole) через API7 — IHoles3D/IHole3D + IHoleDisposal

- 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
This commit is contained in:
2026-05-27 09:53:54 +03:00
parent 56f4a91364
commit 13ab0f6581
5 changed files with 269 additions and 1 deletions
@@ -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<HoleService>()` в `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%)` (торец плоский, погрешность модели). ✓
+100
View File
@@ -0,0 +1,100 @@
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Threading;
using Kompas6Constants3D;
using KompasAPI7;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>
/// Операция «Отверстие» через API7 (<c>IHoles3D</c>/<c>IHole3D</c>). В API5 определения
/// отверстия нет, поэтому используется современный API7 (как и прямое редактирование грани).
/// </summary>
[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));
}
/// <summary>
/// Просверлить простое цилиндрическое отверстие (ksHTBase) на грани, найденной по мировой точке
/// (<paramref name="x"/>,<paramref name="y"/>,<paramref name="z"/>), с центром в этой точке.
/// diameter — диаметр (мм). throughAll — сквозное (depth игнорируется), иначе на глубину depth (мм).
/// </summary>
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;
}
}
}
+1
View File
@@ -22,6 +22,7 @@ builder.Services.AddSingleton<KompasDispatcher>();
builder.Services.AddSingleton<KompasSession>();
builder.Services.AddSingleton<DocumentService>();
builder.Services.AddSingleton<PartModeler>();
builder.Services.AddSingleton<HoleService>();
builder.Services.AddSingleton<SnapshotService>();
builder.Services.AddSingleton<QueryService>();
builder.Services.AddSingleton<ModelInspectionService>();
+15 -1
View File
@@ -7,7 +7,7 @@ namespace Kompas.Mcp.Host.Tools;
/// <summary>Инструменты формообразующих операций.</summary>
[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<string> 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<string> Rebuild()
@@ -0,0 +1,74 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>Интеграция: операция «Отверстие» (API7 IHoles3D/IHole3D).</summary>
[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); }
}
}