diff --git a/docs/superpowers/plans/2026-05-26-shell.md b/docs/superpowers/plans/2026-05-26-shell.md new file mode 100644 index 0000000..a196b4f --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-shell.md @@ -0,0 +1,170 @@ +# Shell (операция «Оболочка») Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`). + +**Goal:** MCP-инструмент `shell` — превратить тело в оболочку заданной толщины, удалив выбранные грани. + +**Architecture:** `ShellAsync` в `PartModeler.Features.cs` (паттерн `NewEntity(o3d_shellOperation)→GetDefinition→FaceArray.Add→thickness/thinType→Create`), helper `SelectFaceByIndex` в ядре, инструмент `shell` в `FeatureTools.cs`. Грани — по индексам из `list_faces`. + +**Tech Stack:** .NET 8 x64, C#, COM API5 (`Kompas6API5.ksShellDefinition`, `Kompas6Constants3D.Obj3dType.o3d_shellOperation`=43). + +--- + +## Task 1: shell end-to-end + +**Files:** +- Modify: `src/Kompas.Mcp.Core/Modeling/PartModeler.cs` (helper) +- Modify: `src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs` (метод) +- Modify: `src/Kompas.Mcp.Host/Tools/FeatureTools.cs` (инструмент) +- Test: `tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs` + +- [ ] **Step 1: Падающий интеграционный тест** `FeatureOpsTests.Shell_box_hollows_with_open_top` + +```csharp +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Query; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: формообразующие операции пакета B. +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class FeatureOpsTests +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly QueryService _query; + + public FeatureOpsTests(KompasFixture fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _modeler = new PartModeler(fx.Session, fx.Dispatcher); + _query = new QueryService(fx.Session, fx.Dispatcher); + } + + [Fact] + public async Task Shell_box_hollows_with_open_top() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Коробка 40×30×20. + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, 0, 0, 40, 30); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: 20); + var before = (await _query.GetPartInfoAsync()).Volume; // 24000 + + // Удаляем одну из двух плоских граней-торцов (площадь 40×30=1200) → открытая оболочка. + var faces = await _query.ListFacesAsync(); + var cap = faces.First(f => f.Type == "plane" && Math.Abs(f.Area - 1200) < 1); + var id = await _modeler.ShellAsync(new[] { cap.Index }, thickness: 2, outward: false); + Assert.True(id > 0); + await _modeler.RebuildAsync(); + + // Оболочка 2 мм с открытым торцом: 24000 − 36×26×18 = 7152 мм³. + var after = (await _query.GetPartInfoAsync()).Volume; + Assert.True(after < before); + Assert.InRange(after, 7152 * 0.95, 7152 * 1.05); + } + finally { await _docs.CloseAsync(save: false); } + } +} +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +Run: `dotnet test -c Release --filter "FullyQualifiedName~FeatureOpsTests.Shell_box_hollows_with_open_top"` +Expected: FAIL (метод `ShellAsync` не существует). + +- [ ] **Step 3: Helper `SelectFaceByIndex`** в `PartModeler.cs` (рядом с `SelectEdgeByIndex`) + +```csharp +/// Выбрать грань детали по индексу из list_faces (с проверкой диапазона). +private static ksEntity SelectFaceByIndex(ksPart part, int faceIndex) +{ + var faces = part.EntityCollection((short)Obj3dType.o3d_face) as ksEntityCollection + ?? throw new InvalidOperationException("Не удалось получить коллекцию граней."); + if (faceIndex < 0 || faceIndex >= faces.GetCount()) + throw new ArgumentOutOfRangeException(nameof(faceIndex), + $"Индекс грани вне диапазона [0; {faces.GetCount() - 1}]. Сверьтесь с list_faces."); + return faces.GetByIndex(faceIndex) as ksEntity + ?? throw new InvalidOperationException("Грань по индексу не приводится к ksEntity."); +} +``` + +- [ ] **Step 4: Метод `ShellAsync`** в `PartModeler.Features.cs` + +```csharp +/// Превратить тело в оболочку: удалить (открыть) грани с индексами faceIndices, +/// задать толщину стенки thickness (мм). outward=false — толщина внутрь (габарит сохраняется), +/// true — наружу. Возвращает id операции. +public Task ShellAsync(IReadOnlyList faceIndices, double thickness, bool outward = false, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + if (thickness <= 0) throw new ArgumentOutOfRangeException(nameof(thickness), "Толщина должна быть > 0."); + ArgumentNullException.ThrowIfNull(faceIndices); + var indices = faceIndices.Distinct().ToList(); + if (indices.Count == 0) throw new ArgumentException("Нужна хотя бы одна удаляемая грань.", nameof(faceIndices)); + + var part = GetTopPart(); + // Проверяем диапазон ВСЕХ индексов до мутации модели. + var faces = indices.Select(i => SelectFaceByIndex(part, i)).ToList(); + + var entity = part.NewEntity((short)Obj3dType.o3d_shellOperation) as ksEntity + ?? throw new InvalidOperationException("NewEntity(o3d_shellOperation) вернул null."); + var def = entity.GetDefinition() as ksShellDefinition + ?? throw new InvalidOperationException("GetDefinition() оболочки вернул не ksShellDefinition."); + var arr = def.FaceArray() as ksEntityCollection + ?? throw new InvalidOperationException("FaceArray() оболочки вернул не ksEntityCollection."); + foreach (var f in faces) arr.Add(f); + def.thickness = thickness; + def.thinType = !outward; // true=внутрь, false=наружу + + if (!entity.Create()) + throw new InvalidOperationException( + "Create() оболочки вернул FALSE (толщина больше локального радиуса/стенки или несовместимая топология?)."); + + var id = _nextId++; + _features[id] = entity; + return id; + }, ct); +``` + +- [ ] **Step 5: Инструмент `shell`** в `FeatureTools.cs` (перед `Rebuild`) + +```csharp +[McpServerTool(Name = "shell")] +[Description("Превратить тело активной детали в оболочку (придать стенкам толщину): удалить (открыть) грани с индексами faceIndices из list_faces и задать толщину стенки thickness (мм). outward=false — толщина внутрь (габарит сохраняется), true — наружу. Возвращает id операции.")] +public async Task Shell( + [Description("Индексы удаляемых (открываемых) граней из list_faces")] int[] faceIndices, + [Description("Толщина стенки, мм")] double thickness, + [Description("Толщина наружу (true) или внутрь (false)")] bool outward = false) +{ + await session.ConnectAsync(); + var id = await modeler.ShellAsync(faceIndices, thickness, outward); + return $"Оболочка толщиной {thickness} мм создана (удалено граней: {faceIndices.Length}), id={id}."; +} +``` + +- [ ] **Step 6: Запустить тест** + +Run: `dotnet test -c Release --filter "FullyQualifiedName~FeatureOpsTests.Shell_box_hollows_with_open_top"` +Expected: PASS (если фактический объём иной — скорректировать ожидание по замеру, сохранив узкий допуск). + +- [ ] **Step 7: Commit** + +```bash +git add src/Kompas.Mcp.Core/Modeling/PartModeler.cs src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs src/Kompas.Mcp.Host/Tools/FeatureTools.cs tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs +git commit -m "feat(feature): операция Оболочка (shell)" +``` + +--- + +## Task 2: документация и завершение + +- [ ] Полный прогон: `dotnet test -c Release --filter "Category=Unit"` и `--filter "Category=Integration"` — всё зелёное. +- [ ] Codex-ревью реализации; внести обоснованные правки. +- [ ] `docs-delegate`: +1 инструмент (→51), +1 тест; `shell` в описание формообразующих; начат пакет B. +- [ ] Merge в `main`. diff --git a/docs/superpowers/specs/2026-05-26-shell-design.md b/docs/superpowers/specs/2026-05-26-shell-design.md index a0e32e9..203bd5f 100644 --- a/docs/superpowers/specs/2026-05-26-shell-design.md +++ b/docs/superpowers/specs/2026-05-26-shell-design.md @@ -57,9 +57,16 @@ ent.Create() - **Helper** `SelectFaceByIndex(ksPart, int)` в ядре `PartModeler.cs` — аналог существующего `SelectEdgeByIndex` (диапазон-проверка + `EntityCollection(o3d_face).GetByIndex`). - **Инструмент** `shell` в `FeatureTools.cs` (как `extrude_boss`/`revolve_boss`). -- **Валидация** (inline, как `ExtrudeAsync`/`RevolveAsync`): `thickness > 0`; - `faceIndices` не null и не пуст. Возврат `Create()==FALSE` → `InvalidOperationException` - с понятным текстом (толщина велика для геометрии?). +- **Валидация** (inline, как `ExtrudeAsync`/`RevolveAsync`): `thickness > 0`; `faceIndices` + не null и не пуст; **каждый индекс проверяется на диапазон до мутации модели** + (`SelectFaceByIndex` бросает `ArgumentOutOfRangeException`, как `SelectEdgeByIndex`); + **дубли индексов отбрасываются** (`Distinct`), чтобы не добавлять грань в `FaceArray` дважды; + `FaceArray()` проверяется на null (`as ksEntityCollection ?? throw`). Возврат + `Create()==FALSE` → `InvalidOperationException` с пояснением вероятных причин (толщина больше + локального радиуса/толщины стенки; несовместимая топология). +- **Известное ограничение** (документируем, не обрабатываем): на телах со скруглениями/фасками и + на вогнутых/криволинейных гранях оболочка может не построиться (`Create()==FALSE`) даже при + формально корректных входных данных — это ограничение ядра КОМПАС, причину API не сообщает. ## Тестирование @@ -67,8 +74,9 @@ ent.Create() `FeatureOpsTests`): коробка 40×30×20 (`AddRectangle` → `Extrude`) → `list_faces` → найти верхнюю грань (нормаль +Z или по площади) → `ShellAsync([верхняя], thickness=2, outward=false)` → объём существенно уменьшается (тело стало полым с открытым верхом) и остаётся > 0. -Проверка: `after < before` и `after ∈ (before·0.2, before·0.5)` (стенки 2 мм у коробки -40×30×20 дают ≈30% исходного объёма). +Точная проверка: оболочка 2 мм с открытым верхом = `24000 − 36×26×18 = 24000 − 16848 = 7152 мм³`. +Утверждение: `after < before` и `after ∈ (7152·0.95, 7152·1.05)` (≈6794…7510 мм³). Если КОМПАС +считает полость иначе — скорректировать ожидание по фактическому замеру, сохранив узкий допуск. Unit отдельный не вводим: маппинг `thinType=!outward` тривиален и инлайнится; валидация параметров — в стиле существующих операций (`extrude`/`revolve` тоже покрыты интеграционно). diff --git a/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs b/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs index 8e03bcd..08359ac 100644 --- a/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs +++ b/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs @@ -196,6 +196,40 @@ public sealed partial class PartModeler return id; } + /// Превратить тело в оболочку: удалить (открыть) грани с индексами faceIndices, + /// задать толщину стенки thickness (мм). outward=false — толщина внутрь (габарит сохраняется), + /// true — наружу. Возвращает id операции. + public Task ShellAsync(IReadOnlyList faceIndices, double thickness, bool outward = false, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + if (thickness <= 0) throw new ArgumentOutOfRangeException(nameof(thickness), "Толщина должна быть > 0."); + ArgumentNullException.ThrowIfNull(faceIndices); + var indices = faceIndices.Distinct().ToList(); + if (indices.Count == 0) throw new ArgumentException("Нужна хотя бы одна удаляемая грань.", nameof(faceIndices)); + + var part = GetTopPart(); + // Проверяем диапазон ВСЕХ индексов до мутации модели. + var faces = indices.Select(i => SelectFaceByIndex(part, i)).ToList(); + + var entity = part.NewEntity((short)Obj3dType.o3d_shellOperation) as ksEntity + ?? throw new InvalidOperationException("NewEntity(o3d_shellOperation) вернул null."); + var def = entity.GetDefinition() as ksShellDefinition + ?? throw new InvalidOperationException("GetDefinition() оболочки вернул не ksShellDefinition."); + var arr = def.FaceArray() as ksEntityCollection + ?? throw new InvalidOperationException("FaceArray() оболочки вернул не ksEntityCollection."); + foreach (var f in faces) arr.Add(f); + def.thickness = thickness; + def.thinType = !outward; // true=внутрь, false=наружу + + if (!entity.Create()) + throw new InvalidOperationException( + "Create() оболочки вернул FALSE (толщина больше локального радиуса/стенки или несовместимая топология?)."); + + var id = _nextId++; + _features[id] = entity; + return id; + }, ct); + /// Перестроить документ. public Task RebuildAsync(CancellationToken ct = default) => _dispatcher.InvokeAsync(() => diff --git a/src/Kompas.Mcp.Core/Modeling/PartModeler.cs b/src/Kompas.Mcp.Core/Modeling/PartModeler.cs index fa534aa..353fb93 100644 --- a/src/Kompas.Mcp.Core/Modeling/PartModeler.cs +++ b/src/Kompas.Mcp.Core/Modeling/PartModeler.cs @@ -80,6 +80,18 @@ public sealed partial class PartModeler : IDisposable ?? throw new InvalidOperationException("Ребро из коллекции не приводится к ksEntity."); } + /// Выбрать грань детали по индексу из list_faces (с проверкой диапазона). + private static ksEntity SelectFaceByIndex(ksPart part, int faceIndex) + { + var faces = part.EntityCollection((short)Obj3dType.o3d_face) as ksEntityCollection + ?? throw new InvalidOperationException("Не удалось получить коллекцию граней."); + if (faceIndex < 0 || faceIndex >= faces.GetCount()) + throw new ArgumentOutOfRangeException(nameof(faceIndex), + $"Индекс грани вне диапазона [0; {faces.GetCount() - 1}]. Сверьтесь с list_faces."); + return faces.GetByIndex(faceIndex) as ksEntity + ?? throw new InvalidOperationException("Грань по индексу не приводится к ksEntity."); + } + /// Выбрать ребро детали по индексу из list_edges. private static ksEntity SelectEdgeByIndex(ksPart part, int edgeIndex) { diff --git a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs index 0dfa09f..d1c481e 100644 --- a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs +++ b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs @@ -91,6 +91,18 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler) return $"Фаска {distance}×{distance} создана на ребре [{edgeIndex}], id={id}."; } + [McpServerTool(Name = "shell")] + [Description("Превратить тело активной детали в оболочку (придать стенкам толщину): удалить (открыть) грани с индексами faceIndices из list_faces и задать толщину стенки thickness (мм). outward=false — толщина внутрь (габарит сохраняется), true — наружу. Возвращает id операции.")] + public async Task Shell( + [Description("Индексы удаляемых (открываемых) граней из list_faces")] int[] faceIndices, + [Description("Толщина стенки, мм")] double thickness, + [Description("Толщина наружу (true) или внутрь (false)")] bool outward = false) + { + await session.ConnectAsync(); + var id = await modeler.ShellAsync(faceIndices, thickness, outward); + return $"Оболочка толщиной {thickness} мм создана (удалено граней: {faceIndices.Length}), id={id}."; + } + [McpServerTool(Name = "rebuild")] [Description("Перестроить активный документ.")] public async Task Rebuild() diff --git a/tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs b/tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs new file mode 100644 index 0000000..756df72 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs @@ -0,0 +1,50 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Query; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: формообразующие операции пакета B. +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class FeatureOpsTests +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly QueryService _query; + + public FeatureOpsTests(KompasFixture fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _modeler = new PartModeler(fx.Session, fx.Dispatcher); + _query = new QueryService(fx.Session, fx.Dispatcher); + } + + [Fact] + public async Task Shell_box_hollows_with_open_top() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Коробка 40×30×20. + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, 0, 0, 40, 30); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: 20); + var before = (await _query.GetPartInfoAsync()).Volume; // 24000 + + // Удаляем одну из двух плоских граней-торцов (площадь 40×30=1200) → открытая оболочка. + var faces = await _query.ListFacesAsync(); + var cap = faces.First(f => f.Type == "plane" && Math.Abs(f.Area - 1200) < 1); + var id = await _modeler.ShellAsync(new[] { cap.Index }, thickness: 2, outward: false); + Assert.True(id > 0); + await _modeler.RebuildAsync(); + + // Оболочка 2 мм с открытым торцом: 24000 − 36×26×18 = 7152 мм³. + var after = (await _query.GetPartInfoAsync()).Volume; + Assert.True(after < before, "После оболочки объём должен уменьшиться."); + Assert.InRange(after, 7152 * 0.95, 7152 * 1.05); + } + finally { await _docs.CloseAsync(save: false); } + } +}