diff --git a/docs/superpowers/specs/2026-05-26-sweep-design.md b/docs/superpowers/specs/2026-05-26-sweep-design.md new file mode 100644 index 0000000..f4fc735 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-sweep-design.md @@ -0,0 +1,55 @@ +# Дизайн: пакет B (шаг 3) — кинематическая операция (sweep) + +**Дата:** 2026-05-26 +**Статус:** на ревью (автономный режим; ревьюер — Codex) + +## Цель + +Добавить формообразующую **кинематическую операцию** (sweep) — переместить профиль вдоль +траектории, образуя тело. Третий шаг пакета B. Один MCP-инструмент `sweep`. В отличие от +loft (требует параллельные сечения → offset-плоскость, пакет E), sweep естественно работает с +базовыми плоскостями: профиль на одной, траектория на перпендикулярной. + +## Сигнатуры COM (рефлексия interop) + +``` +Obj3dType.o3d_baseEvolution = 45 +ksBaseEvolutionDefinition: + short sketchShiftType // тип переноса сечения вдоль пути + bool SetSketch(object) // профиль (сечение) + object PathPartArray() // ksEntityCollection сегментов траектории + bool SetThinParam(bool thin, short thinType, double normal, double reverse) +``` + +**Паттерн:** `NewEntity(o3d_baseEvolution)` → `GetDefinition()` → `SetSketch(профиль)` → +`PathPartArray().Add(траектория)` → `sketchShiftType` → `SetThinParam(false,…)` (сплошное) → +`Create()`. + +## Новый MCP-инструмент (группа Feature) + +| Инструмент | Параметры | Поведение | +|---|---|---| +| `sweep` | `profileSketchId: int`, `pathSketchId: int` | Кинематическая операция: переместить замкнутый профиль `profileSketchId` вдоль траектории `pathSketchId`, образуя сплошное тело. Профиль и траектория — на разных (обычно перпендикулярных) плоскостях; начало траектории совпадает с плоскостью профиля. Возвращает id операции. | + +**Решения (YAGNI):** тонкостенный режим (`thin`) и `sketchShiftType` наружу не выставляем — +сплошное тело, `sketchShiftType=0` (перенос с сохранением угла). Можно расширить позже. + +## Реализация + +- **Метод** `SweepAsync(int profileSketchId, int pathSketchId, CancellationToken)` в + `PartModeler.Features.cs`. Оба эскиза закрываются перед операцией (`CloseSketchCore`); + берутся `sketch.Entity` из реестра. `profileSketchId == pathSketchId` → `ArgumentException`. + Возвращает id операции. +- **Инструмент** `sweep` в `FeatureTools.cs`. +- `PathPartArray()` проверяется на null (`as ksEntityCollection ?? throw`). +- Транзитный `ksBaseEvolutionDefinition` не освобождается — консистентно (долг v2-2). +- `Create()==FALSE` → `InvalidOperationException` (профиль/траектория несовместимы: начало пути + не на плоскости профиля, самопересечение?). + +## Тестирование + +**Integration** (`FeatureOpsTests`): профиль — окружность R5 на XOY (центр 0,0); траектория — +вертикальный отрезок на XOZ `(0,0)→(0,50)` (начинается в центре профиля, идёт по +Z) → +`SweepAsync(profile, path)` → цилиндр R5×H50, объём `π·25·50 ≈ 3927 мм³`. Проверка +`InRange(volume, 3927·0.95, 3927·1.05)`. Если `sketchShiftType=0` не строит — перебрать тип +(0/1) по факту, сохранив проверку объёма. diff --git a/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs b/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs index ce3b205..c9bd871 100644 --- a/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs +++ b/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs @@ -266,6 +266,39 @@ public sealed partial class PartModeler return id; }, ct); + /// Кинематическая операция (sweep): переместить профиль (эскиз profileSketchId) + /// вдоль траектории (эскиз pathSketchId), образуя сплошное тело. Возвращает id операции. + public Task SweepAsync(int profileSketchId, int pathSketchId, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + if (profileSketchId == pathSketchId) + throw new ArgumentException("Профиль и траектория должны быть разными эскизами.", nameof(pathSketchId)); + var profile = RequireSketch(profileSketchId); + var path = RequireSketch(pathSketchId); + CloseSketchCore(profile); // оба эскиза должны быть закрыты перед операцией + CloseSketchCore(path); + + var part = GetTopPart(); + var entity = part.NewEntity((short)Obj3dType.o3d_baseEvolution) as ksEntity + ?? throw new InvalidOperationException("NewEntity(o3d_baseEvolution) вернул null."); + var def = entity.GetDefinition() as ksBaseEvolutionDefinition + ?? throw new InvalidOperationException("GetDefinition() кинематической операции вернул не ksBaseEvolutionDefinition."); + def.SetSketch(profile.Entity); + var pathArr = def.PathPartArray() as ksEntityCollection + ?? throw new InvalidOperationException("PathPartArray() вернул не ksEntityCollection."); + pathArr.Add(path.Entity); + def.sketchShiftType = 0; // перенос сечения с сохранением угла + def.SetThinParam(false, 0, 0, 0); // сплошное тело + + 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.Host/Tools/FeatureTools.cs b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs index 5ded76b..cd050e3 100644 --- a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs +++ b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs @@ -113,6 +113,15 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler) return $"Ребро жёсткости толщиной {thickness} мм создано (side={side}), id={id}."; } + [McpServerTool(Name = "sweep")] + [Description("Кинематическая операция: переместить замкнутый профиль (эскиз profileSketchId) вдоль траектории (эскиз pathSketchId), образуя сплошное тело. Профиль и траектория — на разных плоскостях (обычно перпендикулярных); начало траектории должно совпадать с плоскостью профиля. Возвращает id операции.")] + public async Task Sweep(int profileSketchId, int pathSketchId) + { + await session.ConnectAsync(); + var id = await modeler.SweepAsync(profileSketchId, pathSketchId); + return $"Кинематическая операция создана (профиль {profileSketchId} по траектории {pathSketchId}), 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 index 5429d0c..c5c457c 100644 --- a/tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs +++ b/tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs @@ -81,4 +81,31 @@ public sealed class FeatureOpsTests } finally { await _docs.CloseAsync(save: false); } } + + [Fact] + public async Task Sweep_circle_along_line_makes_cylinder() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Профиль: окружность R5 на XOY (центр 0,0). + var profile = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddCircleAsync(profile, 0, 0, 5); + await _modeler.CloseSketchAsync(profile); + // Траектория: вертикальный отрезок (0,0)→(0,50) на XOZ (по +Z из центра профиля). + var path = await _modeler.OpenSketchAsync(BasePlane.XOZ); + await _modeler.AddLineAsync(path, 0, 0, 0, 50); + await _modeler.CloseSketchAsync(path); + + var id = await _modeler.SweepAsync(profile, path); + Assert.True(id > 0); + await _modeler.RebuildAsync(); + + // Окружность R5 по прямой длиной 50 → цилиндр: π·25·50 ≈ 3927 мм³. + var v = (await _query.GetPartInfoAsync()).Volume; + var expected = Math.PI * 25 * 50; + Assert.InRange(v, expected * 0.95, expected * 1.05); + } + finally { await _docs.CloseAsync(save: false); } + } }