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); }
+ }
+}