fix: правки hole по ревью Codex

- направление сверления — по факту убыли объёма (Update=TRUE даже при сверлении в воздух
  на сквозном отверстии); TryDirection + CurrentVolume (API5 МЦХ) — P2-баг, вскрыт тестом
- валидация double.IsFinite для координат/диаметра/глубины (Infinity проходил мимо >0) — P2
- откат точки-сироты и объекта отверстия при неуспехе (IFeature7.Delete) + RebuildDocument — P2
- тест Hole_through_bottom_face_uses_direction_fallback (нижняя грань) — P3
- 104 теста зелёных
This commit is contained in:
2026-05-27 10:04:22 +03:00
parent 13ab0f6581
commit d3dd78ec0b
3 changed files with 105 additions and 27 deletions
+65 -22
View File
@@ -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;
}
}
/// <summary>Построить отверстие в заданном направлении и подтвердить, что материал убыл
/// (объём уменьшился). Возвращает true только при фактическом удалении материала.</summary>
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;
}
/// <summary>Текущий объём детали (мм³) через API5 МЦХ — согласован с геометрией API7 после
/// RebuildDocument (как в QueryService).</summary>
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)