feat: типы отверстий — цековка и зенковка (hole_counterbore, hole_countersink) через API7

- HoleCore отрефакторен: параметр holeType + Action<IHole3D> configure (настройка HoleParameters)
- CounterboreHoleAsync (ksHTCounterbore + ISpotfacingHoleParameters: SpotfacingDiameter/Depth)
- CountersinkHoleAsync (ksHTCountersinking + ICountersinkHoleParameters: ksCTDiameterAngle, Diameter/Angle)
- переиспользуется размещение/подбор направления/откат из базового hole
- инструменты hole_counterbore, hole_countersink; интеграционные тесты (объём ±10% / больше базы)
- 69 инструментов, 118 тестов; спек docs/superpowers/specs/2026-05-27-hole-types-design.md
This commit is contained in:
2026-05-27 11:59:15 +03:00
parent 9079bec375
commit aec2f1e52f
4 changed files with 178 additions and 3 deletions
@@ -0,0 +1,51 @@
# Дизайн: типы отверстий — цековка и зенковка (расширение hole)
**Дата:** 2026-05-27
**Статус:** реализовано и проверено (тесты зелёные); на ревью (ревьюер — Codex)
## Цель
Расширить `hole` (простое цилиндрическое, `ksHTBase`) двумя ходовыми типами под крепёж:
**цековка** (`hole_counterbore`, под винт с цилиндрической головкой) и **зенковка**
(`hole_countersink`, под винт с потайной головкой). Через API7 (как базовое отверстие).
## Сигнатуры (рефлексия interop)
```
IHole3D.HoleType = ksHoleTypeEnum: ksHTBase=0, ksHTCounterbore=1, ksHTCountersinking=2, ksHTConic=4
IHole3D.HoleParameters : IKompasAPIObject // приводится к типу по HoleType (после установки HoleType)
ISpotfacingHoleParameters (цековка): SpotfacingDiameter, SpotfacingDepth (мм)
ICountersinkHoleParameters (зенковка): CountersinkType (ksCountersinkTypeEnum), CountersinkDiameter, CountersinkAngle (°), CountersinkDepth
ksCountersinkTypeEnum: ksCTDiameterAngle=0, ksCTDepthAngle=1, ksCTDiameterDepth=2
```
## MCP-инструменты (группа Feature)
| Инструмент | Параметры | Поведение |
|---|---|---|
| `hole_counterbore` | `x,y,z`, `diameter`, `spotfaceDiameter`, `spotfaceDepth`, `depth=0`, `throughAll=false` | Отверстие Ø`diameter` + цилиндрическая цековка Ø`spotfaceDiameter`×`spotfaceDepth` сверху. |
| `hole_countersink` | `x,y,z`, `diameter`, `sinkDiameter`, `sinkAngle`, `depth=0`, `throughAll=false` | Отверстие Ø`diameter` + коническая зенковка Ø`sinkDiameter` под углом `sinkAngle`° (тип `ksCTDiameterAngle`). |
**Решения:** конический тип (`ksHTConic`) и резьба (`ShowThread`/`IThread`) — позже. Зенковка задаётся
парой диаметр+угол (`ksCTDiameterAngle`) — самый привычный ввод.
## Реализация
- **`HoleCore` отрефакторен**: добавлены параметры `ksHoleTypeEnum holeType` и `Action<IHole3D>? configure`
(конфигуратор `HoleParameters` после установки `HoleType`). `HoleAsync``ksHTBase, null`.
Вся остальная логика (размещение через `IHoleDisposal`+`Points3D`, подбор направления по убыли
объёма, откат «сирот») переиспользуется.
- **`CounterboreHoleAsync`**: валидация (`spotDiameter>diameter`, `spotDepth>0`, finite) →
`HoleCore(..., ksHTCounterbore, hole => set ISpotfacingHoleParameters)`.
- **`CountersinkHoleAsync`**: валидация (`sinkDiameter>diameter`, `sinkAngle∈(0;180)`, finite) →
`HoleCore(..., ksHTCountersinking, hole => set ICountersinkHoleParameters: type=ksCTDiameterAngle)`.
- **Инструменты** `hole_counterbore`, `hole_countersink` в `FeatureTools.cs`.
## Тестирование (Integration, `HoleTests`)
- **Цековка**: коробка 40×40×20 → `hole_counterbore(0,0,20, Ø10, spot Ø20×5, throughAll)`
удалено = база (π·25·20≈1571) + кольцо цековки (π·75·5≈1178) ≈ 2749 (`±10%`); и
`removed > базовый цилиндр·1.1` (уширение реально вырезано). ✓
- **Зенковка**: коробка → `hole_countersink(0,0,20, Ø8, sink Ø16, 90°, throughAll)`
`removed > база Ø8 (π·16·20≈1005)` и `< база·2` (конус добавил материал, в разумных пределах;
точный объём зависит от трактовки угла). ✓
+44 -3
View File
@@ -28,9 +28,49 @@ public sealed class HoleService
/// 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);
=> _dispatcher.InvokeAsync(() => HoleCore(x, y, z, diameter, depth, throughAll, ksHoleTypeEnum.ksHTBase, null), ct);
private void HoleCore(double x, double y, double z, double diameter, double depth, bool throughAll)
/// <summary>Отверстие с цековкой (цилиндрическим уширением сверху, под винт с цилиндрической
/// головкой): основное отверстие diameter + цековка spotDiameter×spotDepth.</summary>
public Task CounterboreHoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll,
double spotDiameter, double spotDepth, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!double.IsFinite(spotDiameter) || spotDiameter <= diameter)
throw new ArgumentOutOfRangeException(nameof(spotDiameter), "Диаметр цековки должен быть больше диаметра отверстия.");
if (!double.IsFinite(spotDepth) || spotDepth <= 0)
throw new ArgumentOutOfRangeException(nameof(spotDepth), "Глубина цековки должна быть > 0.");
HoleCore(x, y, z, diameter, depth, throughAll, ksHoleTypeEnum.ksHTCounterbore, hole =>
{
var p = hole.HoleParameters as ISpotfacingHoleParameters
?? throw new InvalidOperationException("HoleParameters не приводится к ISpotfacingHoleParameters.");
p.SpotfacingDiameter = spotDiameter;
p.SpotfacingDepth = spotDepth;
});
}, ct);
/// <summary>Отверстие с зенковкой (коническим уширением сверху, под винт с потайной головкой):
/// основное отверстие diameter + зенковка sinkDiameter под углом sinkAngle (градусы, конус).</summary>
public Task CountersinkHoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll,
double sinkDiameter, double sinkAngle, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!double.IsFinite(sinkDiameter) || sinkDiameter <= diameter)
throw new ArgumentOutOfRangeException(nameof(sinkDiameter), "Диаметр зенковки должен быть больше диаметра отверстия.");
if (!double.IsFinite(sinkAngle) || sinkAngle <= 0 || sinkAngle >= 180)
throw new ArgumentOutOfRangeException(nameof(sinkAngle), "Угол зенковки должен быть в (0; 180) градусов.");
HoleCore(x, y, z, diameter, depth, throughAll, ksHoleTypeEnum.ksHTCountersinking, hole =>
{
var p = hole.HoleParameters as ICountersinkHoleParameters
?? throw new InvalidOperationException("HoleParameters не приводится к ICountersinkHoleParameters.");
p.CountersinkType = ksCountersinkTypeEnum.ksCTDiameterAngle; // задаём диаметр + угол
p.CountersinkDiameter = sinkDiameter;
p.CountersinkAngle = sinkAngle;
});
}, ct);
private void HoleCore(double x, double y, double z, double diameter, double depth, bool throughAll,
ksHoleTypeEnum holeType, Action<IHole3D>? configure)
{
if (!double.IsFinite(x) || !double.IsFinite(y) || !double.IsFinite(z))
throw new ArgumentException("Координаты точки отверстия должны быть конечными числами.");
@@ -68,13 +108,14 @@ public sealed class HoleService
{
hole = container.Holes3D.Add()
?? throw new InvalidOperationException("Holes3D.Add() вернул null.");
hole.HoleType = ksHoleTypeEnum.ksHTBase;
hole.HoleType = holeType;
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;
configure?.Invoke(hole); // параметры цековки/зенковки (после установки HoleType)
var disp = (IHoleDisposal)hole;
disp.BaseSurface = (IModelObject)face;
+32
View File
@@ -212,6 +212,38 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler, Hol
return $"Уклон {angle}° создан на {count} гранях (нейтральная плоскость {neutralPlane}), id={id}.";
}
[McpServerTool(Name = "hole_counterbore")]
[Description("Отверстие с ЦЕКОВКОЙ (цилиндрическое уширение сверху — под винт с цилиндрической головкой) на грани в точке (x,y,z — центр). diameter — диаметр отверстия (мм). depth/throughAll — как у hole. spotfaceDiameter — диаметр цековки (мм, больше diameter), spotfaceDepth — глубина цековки (мм). Направление в тело — авто.")]
public async Task<string> HoleCounterbore(
double x, double y, double z,
[Description("Диаметр отверстия, мм")] double diameter,
[Description("Диаметр цековки, мм (> diameter)")] double spotfaceDiameter,
[Description("Глубина цековки, мм")] double spotfaceDepth,
[Description("Глубина отверстия, мм (если throughAll=false)")] double depth = 0,
[Description("Сквозное отверстие")] bool throughAll = false)
{
await session.ConnectAsync();
await holes.CounterboreHoleAsync(x, y, z, diameter, depth, throughAll, spotfaceDiameter, spotfaceDepth);
var kind = throughAll ? "сквозное" : $"глубина {depth} мм";
return $"Отверстие с цековкой Ø{diameter} (цековка Ø{spotfaceDiameter}×{spotfaceDepth}, {kind}) создано в точке ({x}, {y}, {z}).";
}
[McpServerTool(Name = "hole_countersink")]
[Description("Отверстие с ЗЕНКОВКОЙ (коническое уширение сверху — под винт с потайной головкой) на грани в точке (x,y,z — центр). diameter — диаметр отверстия (мм). depth/throughAll — как у hole. sinkDiameter — диаметр зенковки (мм, больше diameter), sinkAngle — угол конуса зенковки (градусы, 0..180; типично 90). Направление в тело — авто.")]
public async Task<string> HoleCountersink(
double x, double y, double z,
[Description("Диаметр отверстия, мм")] double diameter,
[Description("Диаметр зенковки, мм (> diameter)")] double sinkDiameter,
[Description("Угол конуса зенковки, градусы (0..180, типично 90)")] double sinkAngle,
[Description("Глубина отверстия, мм (если throughAll=false)")] double depth = 0,
[Description("Сквозное отверстие")] bool throughAll = false)
{
await session.ConnectAsync();
await holes.CountersinkHoleAsync(x, y, z, diameter, depth, throughAll, sinkDiameter, sinkAngle);
var kind = throughAll ? "сквозное" : $"глубина {depth} мм";
return $"Отверстие с зенковкой Ø{diameter} (зенковка Ø{sinkDiameter}, угол {sinkAngle}°, {kind}) создано в точке ({x}, {y}, {z}).";
}
[McpServerTool(Name = "rebuild")]
[Description("Перестроить активный документ.")]
public async Task<string> Rebuild()
@@ -96,4 +96,55 @@ public sealed class HoleTests
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Counterbore_removes_base_plus_spotface()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
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;
// Ø10 сквозное + цековка Ø20×5 по центру верхней грани.
await _holes.CounterboreHoleAsync(0, 0, 20, diameter: 10, depth: 0, throughAll: true,
spotDiameter: 20, spotDepth: 5);
var removed = before - (await _query.GetPartInfoAsync()).Volume;
// База: π·25·20 ≈ 1570.8; + кольцо цековки (π·100−π·25)·5 ≈ 1178 → итого ≈ 2749.
var baseCyl = Math.PI * 25 * 20;
var expected = Math.PI * 25 * 20 + Math.PI * (100 - 25) * 5;
Assert.True(removed > baseCyl * 1.1, "Цековка должна удалить больше, чем простое отверстие.");
Assert.InRange(removed, expected * 0.9, expected * 1.1);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Countersink_removes_base_plus_cone()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
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;
// Ø8 сквозное + зенковка Ø16 под 90° по центру верхней грани.
await _holes.CountersinkHoleAsync(0, 0, 20, diameter: 8, depth: 0, throughAll: true,
sinkDiameter: 16, sinkAngle: 90);
var removed = before - (await _query.GetPartInfoAsync()).Volume;
// База Ø8 сквозь 20 ≈ 1005; зенковка-конус добавляет ~270 (точное зависит от трактовки угла).
var baseCyl = Math.PI * 16 * 20;
Assert.True(removed > baseCyl, "Зенковка должна удалить больше, чем простое отверстие Ø8.");
Assert.True(removed < baseCyl * 2, "Объём зенковки в разумных пределах.");
}
finally { await _docs.CloseAsync(save: false); }
}
}