115fcb8759
ShellAsync (o3d_shellOperation): удаление граней по индексам + толщина стенки, thinType=!outward. SelectFaceByIndex в ядре, инструмент shell в FeatureTools. Валидация: thickness>0, дедупликация индексов, проверка диапазона до мутации. Integration-тест: коробка 40x30x20 → оболочка 2мм → 7152 мм³ (±5%). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
9.0 KiB
Markdown
171 lines
9.0 KiB
Markdown
# 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;
|
||
|
||
/// <summary>Интеграция: формообразующие операции пакета B.</summary>
|
||
[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
|
||
/// <summary>Выбрать грань детали по индексу из list_faces (с проверкой диапазона).</summary>
|
||
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
|
||
/// <summary>Превратить тело в оболочку: удалить (открыть) грани с индексами faceIndices,
|
||
/// задать толщину стенки thickness (мм). outward=false — толщина внутрь (габарит сохраняется),
|
||
/// true — наружу. Возвращает id операции.</summary>
|
||
public Task<int> ShellAsync(IReadOnlyList<int> 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<string> 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`.
|