diff --git a/docs/superpowers/specs/2026-05-27-hole-types-design.md b/docs/superpowers/specs/2026-05-27-hole-types-design.md new file mode 100644 index 0000000..3a92069 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-hole-types-design.md @@ -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? 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` (конус добавил материал, в разумных пределах; + точный объём зависит от трактовки угла). ✓ diff --git a/src/Kompas.Mcp.Core/Modeling/HoleService.cs b/src/Kompas.Mcp.Core/Modeling/HoleService.cs index c8fc9a9..ecfa7ce 100644 --- a/src/Kompas.Mcp.Core/Modeling/HoleService.cs +++ b/src/Kompas.Mcp.Core/Modeling/HoleService.cs @@ -28,9 +28,49 @@ public sealed class HoleService /// 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); + => _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) + /// Отверстие с цековкой (цилиндрическим уширением сверху, под винт с цилиндрической + /// головкой): основное отверстие diameter + цековка spotDiameter×spotDepth. + 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); + + /// Отверстие с зенковкой (коническим уширением сверху, под винт с потайной головкой): + /// основное отверстие diameter + зенковка sinkDiameter под углом sinkAngle (градусы, конус). + 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? 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; diff --git a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs index e280357..f045718 100644 --- a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs +++ b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs @@ -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 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 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 Rebuild() diff --git a/tests/Kompas.Mcp.Tests/Integration/HoleTests.cs b/tests/Kompas.Mcp.Tests/Integration/HoleTests.cs index 46795f9..9f7d00e 100644 --- a/tests/Kompas.Mcp.Tests/Integration/HoleTests.cs +++ b/tests/Kompas.Mcp.Tests/Integration/HoleTests.cs @@ -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); } + } }