diff --git a/docs/superpowers/specs/2026-05-27-hole-design.md b/docs/superpowers/specs/2026-05-27-hole-design.md index 2740def..15ed4a1 100644 --- a/docs/superpowers/specs/2026-05-27-hole-design.md +++ b/docs/superpowers/specs/2026-05-27-hole-design.md @@ -38,11 +38,18 @@ 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(); +d.Direction = true; // подбор по факту (см. ниже) +hole.Update(); doc3d.RebuildDocument(); // проверяем убыль объёма; иначе Direction=false ``` +**Направление сверления (важно):** нормаль грани ориентирована непредсказуемо (тот же класс +проблем, что у boss/cut в OPEN_QUESTIONS), а при **сквозном** отверстии `Update()` возвращает +`TRUE` даже когда сверлит «в воздух» (наружу тела → удалено 0 материала). Поэтому направление +подбираем **по факту удаления материала**: строим с `Direction=true`, перестраиваем, сравниваем +объём (API5 `CalcMassInertiaProperties`, согласован с API7-геометрией после `RebuildDocument`); +если объём не убыл — `Direction=false` и повтор; если оба не убавили — ошибка. Только успех +Update недостаточен. + **Размещение** (главная неочевидность, решена): у `IHole3D` нет свойства позиции — оно в `IHoleDisposal` (`BaseSurface` = грань + `AssociationVertex` = точка/вершина). Самый надёжный способ задать центр по мировым координатам — создать `IPoint3D` (`ksPParamCoord` + X/Y/Z) и @@ -65,8 +72,9 @@ doc3d.RebuildDocument(); ## Реализация - **`src/Kompas.Mcp.Core/Modeling/HoleService.cs`** (новый): `HoleAsync(x,y,z,diameter,depth,throughAll,ct)` - на STA-потоке; валидация `diameter>0`, `depth>0` (если не throughAll); `FindFaceAtPoint` - (копия паттерна из `FaceEditService`); авто-направление с повтором. + на STA-потоке; валидация `double.IsFinite` для координат/диаметра/глубины + `>0`; `FindFaceAtPoint` + (копия паттерна из `FaceEditService`); подбор направления по убыли объёма (`TryDirection`); + при неуспехе — откат точки и объекта отверстия (`IFeature7.Delete`), затем `RebuildDocument`. - **DI:** `AddSingleton()` в `Program.cs`. - **Инструмент** `hole` в `FeatureTools.cs` (конструктор получает `HoleService`). - Транзитные RCW не освобождаем точечно — консистентно (долг v2-2). @@ -77,3 +85,5 @@ doc3d.RebuildDocument(); удалён цилиндр π·25·20 ≈ 1570.8 мм³; `InRange(before-after, ±5%)`. ✓ 2. **Глухое**: та же коробка → `hole(0,0,20, Ø8, depth=10)` → удалён π·16·10 ≈ 502.7 мм³; `InRange(before-after, ±10%)` (торец плоский, погрешность модели). ✓ +3. **Нижняя грань (подбор направления)**: та же коробка → `hole(0,0,0, Ø10, throughAll)` со стороны + нижней грани → корректное направление внутрь (+Z) подбирается по убыли объёма; удалён π·25·20. ✓ diff --git a/src/Kompas.Mcp.Core/Modeling/HoleService.cs b/src/Kompas.Mcp.Core/Modeling/HoleService.cs index 0612496..c8fc9a9 100644 --- a/src/Kompas.Mcp.Core/Modeling/HoleService.cs +++ b/src/Kompas.Mcp.Core/Modeling/HoleService.cs @@ -1,5 +1,6 @@ using System.Runtime.Versioning; using Kompas.Mcp.Core.Threading; +using Kompas6API5; using Kompas6Constants3D; using KompasAPI7; @@ -31,8 +32,12 @@ public sealed class HoleService 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 (!double.IsFinite(x) || !double.IsFinite(y) || !double.IsFinite(z)) + throw new ArgumentException("Координаты точки отверстия должны быть конечными числами."); + if (!double.IsFinite(diameter) || diameter <= 0) + throw new ArgumentOutOfRangeException(nameof(diameter), "Диаметр должен быть конечным и > 0."); + if (!throughAll && (!double.IsFinite(depth) || depth <= 0)) + throw new ArgumentOutOfRangeException(nameof(depth), "Глубина должна быть конечной и > 0 (или throughAll)."); if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d) throw new InvalidOperationException("Активный документ не является 3D-документом."); @@ -54,30 +59,68 @@ public sealed class HoleService 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 before = CurrentVolume(); - var disp = (IHoleDisposal)hole; - disp.BaseSurface = (IModelObject)face; - disp.Perpendicular = true; - disp.AssociationVertex = (IModelObject)pt; - disp.Direction = true; - - if (!hole.Update()) + // Если отверстие не построится — удаляем уже созданную точку (и сам объект отверстия), + // чтобы не засорять дерево «сиротами» при неуспехе. + IHole3D? hole = null; + try { - disp.Direction = false; - if (!hole.Update()) - throw new InvalidOperationException("Update() отверстия вернул FALSE для обоих направлений."); - } + 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; + + // Направление сверления подбираем ПО ФАКТУ удаления материала: нормаль грани может + // смотреть в любую сторону, а при сквозном отверстии Update() возвращает TRUE даже когда + // сверлит «в воздух» (наружу тела) — поэтому одного флага успеха Update недостаточно. + if (!TryDirection(hole, disp, doc3d, true, before) && + !TryDirection(hole, disp, doc3d, false, before)) + throw new InvalidOperationException( + "Отверстие не удалило материал ни в одном направлении (точка вне тела или диаметр/глубина несовместимы?)."); + } + catch + { + // Best-effort откат: сначала объект отверстия, затем точка размещения. + try { hole?.Owner?.Delete(); } catch { /* лучшее усилие */ } + try { pt.Owner?.Delete(); } catch { /* лучшее усилие */ } + doc3d.RebuildDocument(); + throw; + } + } + + /// Построить отверстие в заданном направлении и подтвердить, что материал убыл + /// (объём уменьшился). Возвращает true только при фактическом удалении материала. + private bool TryDirection(IHole3D hole, IHoleDisposal disp, IKompasDocument3D doc3d, bool direction, double before) + { + disp.Direction = direction; + if (!hole.Update()) return false; doc3d.RebuildDocument(); + return CurrentVolume() < before - 1e-6; + } + + /// Текущий объём детали (мм³) через API5 МЦХ — согласован с геометрией API7 после + /// RebuildDocument (как в QueryService). + private double CurrentVolume() + { + const int mixMmKg = 0x1 | 0x10; // ST_MIX_MM | ST_MIX_KG + var doc3d = _session.Kompas.ActiveDocument3D() as ksDocument3D + ?? throw new InvalidOperationException("Нет активного 3D-документа (API5)."); + var part = doc3d.GetPart((short)Part_Type.pTop_Part) as ksPart + ?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart)."); + var mass = part.CalcMassInertiaProperties(mixMmKg) as ksMassInertiaParam + ?? throw new InvalidOperationException("CalcMassInertiaProperties вернул null."); + return mass.v; } private static IFace? FindFaceAtPoint(IPart7 part, double x, double y, double z) diff --git a/tests/Kompas.Mcp.Tests/Integration/HoleTests.cs b/tests/Kompas.Mcp.Tests/Integration/HoleTests.cs index 88d4a9e..46795f9 100644 --- a/tests/Kompas.Mcp.Tests/Integration/HoleTests.cs +++ b/tests/Kompas.Mcp.Tests/Integration/HoleTests.cs @@ -71,4 +71,29 @@ public sealed class HoleTests } finally { await _docs.CloseAsync(save: false); } } + + [Fact] + public async Task Hole_through_bottom_face_uses_direction_fallback() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Коробка 40×40×20, нижняя грань на Z=0 (нормаль смотрит наружу, −Z). + 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 + + // Сверлим со стороны НИЖНЕЙ грани (0,0,0): корректное направление в тело — внутрь (+Z), + // т.е. противоположно нормали → проверяет авто-подбор направления (fallback). + await _holes.HoleAsync(0, 0, 0, diameter: 10, depth: 0, throughAll: true); + var after = (await _query.GetPartInfoAsync()).Volume; + + var removed = Math.PI * 25 * 20; + Assert.True(after < before, "Отверстие с нижней грани должно построиться (fallback направления)."); + Assert.InRange(before - after, removed * 0.95, removed * 1.05); + } + finally { await _docs.CloseAsync(save: false); } + } }