diff --git a/docs/superpowers/specs/2026-05-26-offset-plane-loft-design.md b/docs/superpowers/specs/2026-05-26-offset-plane-loft-design.md new file mode 100644 index 0000000..835f336 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-offset-plane-loft-design.md @@ -0,0 +1,60 @@ +# Дизайн: смещённая плоскость + операция по сечениям (loft) + +**Дата:** 2026-05-26 +**Статус:** на ревью (автономный режим; ревьюер — Codex) + +## Цель + +Два связанных инструмента: +1. **`sketch_create_on_offset_plane`** — эскиз на плоскости, смещённой от базовой на `offset` мм + (вспомогательная геометрия; снимает ограничение «только 3 базовые плоскости»). +2. **`loft`** — операция по сечениям (несколько эскизов-сечений → сплошное тело). Сечения + строятся на параллельных смещённых плоскостях, что и разблокирует первый инструмент. + +## Сигнатуры COM (рефлексия interop) + +``` +Obj3dType.o3d_planeOffset = 14 +ksPlaneOffsetDefinition: + double offset + bool direction + bool SetPlane(object basePlane) // базовая плоскость (o3d_planeXOY/XOZ/YOZ) + +Obj3dType.o3d_baseLoft = 30 +ksBaseLoftDefinition: + bool SetLoftParam(bool closed, bool flipVertex, bool autoPath) + bool SetThinParam(bool thin, short thinType, double normal, double reverse) + object Sketchs() // ksEntityCollection эскизов-сечений +``` + +## Новые MCP-инструменты + +| Инструмент | Параметры | Поведение | +|---|---|---| +| `sketch_create_on_offset_plane` | `basePlane: string (XOY/XOZ/YOZ)`, `offset: double`, `direction: bool = true` | Создать смещённую плоскость (`o3d_planeOffset`: база + offset мм + направление) и открыть на ней эскиз. Возвращает id эскиза (как `sketch_create`). | +| `loft` | `sketchIds: int[]`, `closed: bool = false` | Операция по сечениям: построить сплошное тело по ≥2 закрытым эскизам-сечениям (в порядке списка). `closed` — замкнуть в кольцо. Возвращает id операции. | + +## Реализация + +- **`OpenSketchOnOffsetPlaneAsync(BasePlane, double offset, bool direction, ct)`** в + `PartModeler.Sketch.cs`: `NewEntity(o3d_planeOffset)` → `ksPlaneOffsetDefinition` + (`SetPlane(GetDefaultEntity(базовая))`, `offset`, `direction`) → `Create()` → `CreateSketchOn(part, planeEntity)`. + Валидация `double.IsFinite(offset)`. +- **`LoftAsync(IReadOnlyList sketchIds, bool closed, ct)`** в `PartModeler.Features.cs`: + закрыть все эскизы; `NewEntity(o3d_baseLoft)` → `ksBaseLoftDefinition` → `Sketchs()` (null-check) + → `Add` каждого `sketch.Entity` в порядке списка → `SetLoftParam(closed, false, true)` → + `SetThinParam(false, 0, 0, 0)` → `Create()`. Валидация: `sketchIds` не null, `Distinct` ≥ 2; + каждый id есть в реестре (`RequireSketch`). +- Инструменты в `SketchTools.cs` (`sketch_create_on_offset_plane`) и `FeatureTools.cs` (`loft`). +- Транзитные RCW не освобождаются — консистентно (долг v2-2). + +## Тестирование + +**Integration** (`FeatureOpsTests`): +1. `OffsetPlane_sketch_extrudes_at_height`: эскиз на XOY+offset 20 → окружность R5 → extrude 10 → + габарит по Z ≈ [20; 30] (`box.MinZ ≈ 20`), объём ≈ π·25·10. +2. `Loft_two_sections_makes_solid`: сечение 1 — квадрат 20×20 на XOY (offset 0, через + `sketch_create_on_offset_plane` с offset 0 или базовый `sketch_create`); сечение 2 — квадрат + 20×20 на XOY+offset 40 → `loft([s1,s2])` → призма ~20×20×40, объём ≈ 16000 мм³ + (`InRange ±10%`, т.к. форма сечения сохраняется). Если loft капризничает на квадратах — + проверить `volume > 0` и габарит Z ≈ 40. diff --git a/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs b/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs index c9bd871..b8dbd0e 100644 --- a/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs +++ b/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs @@ -299,6 +299,38 @@ public sealed partial class PartModeler return id; }, ct); + /// Операция по сечениям (loft): построить сплошное тело по ≥2 закрытым эскизам-сечениям + /// (в порядке списка). closed — замкнуть сечения в кольцо. Возвращает id операции. + public Task LoftAsync(IReadOnlyList sketchIds, bool closed = false, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + ArgumentNullException.ThrowIfNull(sketchIds); + var ids = sketchIds.Distinct().ToList(); + if (ids.Count < 2) + throw new ArgumentException("Нужно минимум 2 сечения для операции по сечениям.", nameof(sketchIds)); + var sketches = ids.Select(RequireSketch).ToList(); // проверяем все id до мутации + foreach (var s in sketches) CloseSketchCore(s); + + var part = GetTopPart(); + var entity = part.NewEntity((short)Obj3dType.o3d_baseLoft) as ksEntity + ?? throw new InvalidOperationException("NewEntity(o3d_baseLoft) вернул null."); + var def = entity.GetDefinition() as ksBaseLoftDefinition + ?? throw new InvalidOperationException("GetDefinition() операции по сечениям вернул не ksBaseLoftDefinition."); + var arr = def.Sketchs() as ksEntityCollection + ?? throw new InvalidOperationException("Sketchs() вернул не ksEntityCollection."); + foreach (var s in sketches) arr.Add(s.Entity); + def.SetLoftParam(closed, false, true); // closed, flipVertex=false, autoPath=true + 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.Core/Modeling/PartModeler.Sketch.cs b/src/Kompas.Mcp.Core/Modeling/PartModeler.Sketch.cs index 392532b..01adf8c 100644 --- a/src/Kompas.Mcp.Core/Modeling/PartModeler.Sketch.cs +++ b/src/Kompas.Mcp.Core/Modeling/PartModeler.Sketch.cs @@ -60,6 +60,30 @@ public sealed partial class PartModeler return CreateSketchOn(part, face); }, ct); + /// + /// Создать смещённую плоскость (база + offset мм) и открыть на ней эскиз. Возвращает id эскиза. + /// Снимает ограничение «только базовые плоскости»: эскиз на произвольной высоте/смещении. + /// + public Task OpenSketchOnOffsetPlaneAsync(BasePlane basePlane, double offset, bool direction = true, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + if (!double.IsFinite(offset)) + throw new ArgumentException("Смещение должно быть конечным числом.", nameof(offset)); + var part = GetTopPart(); + var planeEntity = part.NewEntity((short)Obj3dType.o3d_planeOffset) as ksEntity + ?? throw new InvalidOperationException("NewEntity(o3d_planeOffset) вернул null."); + var pdef = planeEntity.GetDefinition() as ksPlaneOffsetDefinition + ?? throw new InvalidOperationException("GetDefinition() плоскости вернул не ksPlaneOffsetDefinition."); + var baseEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(basePlane)) as ksEntity + ?? throw new InvalidOperationException("Не удалось получить базовую плоскость."); + pdef.SetPlane(baseEntity); + pdef.offset = offset; + pdef.direction = direction; + if (!planeEntity.Create()) + throw new InvalidOperationException("Create() смещённой плоскости вернул FALSE."); + return CreateSketchOn(part, planeEntity); + }, ct); + /// Общее ядро: создать эскиз на заданной плоскости/грани и открыть на редактирование. private int CreateSketchOn(ksPart part, ksEntity planeEntity) { diff --git a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs index 497d5a2..4bea927 100644 --- a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs +++ b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs @@ -122,6 +122,17 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler) return $"Кинематическая операция создана (профиль {profileSketchId} по траектории {pathSketchId}), id={id}."; } + [McpServerTool(Name = "loft")] + [Description("Операция по сечениям (loft): построить сплошное тело по нескольким закрытым эскизам-сечениям (sketchIds, минимум 2, в порядке списка). Сечения обычно на параллельных плоскостях — используйте sketch_create_on_offset_plane для сечений на разной высоте. closed=true — замкнуть сечения в кольцо. Возвращает id операции.")] + public async Task Loft( + [Description("Id эскизов-сечений (минимум 2, в порядке построения)")] int[] sketchIds, + [Description("Замкнуть сечения в кольцо")] bool closed = false) + { + await session.ConnectAsync(); + var id = await modeler.LoftAsync(sketchIds, closed); + return $"Операция по сечениям создана ({sketchIds.Length} сечений), id={id}."; + } + [McpServerTool(Name = "rebuild")] [Description("Перестроить активный документ.")] public async Task Rebuild() diff --git a/src/Kompas.Mcp.Host/Tools/SketchTools.cs b/src/Kompas.Mcp.Host/Tools/SketchTools.cs index 04a548f..6e55025 100644 --- a/src/Kompas.Mcp.Host/Tools/SketchTools.cs +++ b/src/Kompas.Mcp.Host/Tools/SketchTools.cs @@ -27,6 +27,18 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler) return $"Эскиз создан на грани в точке ({x}, {y}, {z}), id={id}. Координаты геометрии — в локальной плоскости эскиза."; } + [McpServerTool(Name = "sketch_create_on_offset_plane")] + [Description("Создать эскиз на плоскости, смещённой от базовой (XOY|XOZ|YOZ) на offset мм, и открыть на редактирование. Позволяет строить геометрию на произвольной высоте/смещении (например сечения для операции loft). direction — направление смещения от базовой плоскости. Возвращает id эскиза.")] + public async Task CreateOnOffsetPlane( + [Description("Базовая плоскость: XOY | XOZ | YOZ")] string basePlane, + [Description("Смещение от базовой плоскости, мм")] double offset, + [Description("Прямое направление смещения")] bool direction = true) + { + await session.ConnectAsync(); + var id = await modeler.OpenSketchOnOffsetPlaneAsync(BasePlanes.Parse(basePlane), offset, direction); + return $"Эскиз создан на смещённой плоскости {basePlane}+{offset}, id={id}."; + } + [McpServerTool(Name = "sketch_add_circle")] [Description("Добавить окружность в открытый эскиз. Координаты — в плоскости эскиза, мм.")] public async Task AddCircle(int sketchId, double centerX, double centerY, double radius) diff --git a/tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs b/tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs index ce102b4..33a8de2 100644 --- a/tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs +++ b/tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs @@ -114,4 +114,49 @@ public sealed class FeatureOpsTests } finally { await _docs.CloseAsync(save: false); } } + + [Fact] + public async Task OffsetPlane_sketch_extrudes_at_height() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Эскиз на XOY, смещённой на +20 по Z → цилиндр R5×H10 с основанием на Z≈20. + var s = await _modeler.OpenSketchOnOffsetPlaneAsync(BasePlane.XOY, offset: 20); + await _modeler.AddCircleAsync(s, 0, 0, 5); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: 10); + await _modeler.RebuildAsync(); + + var box = await _query.GetBoundingBoxAsync(); + Assert.InRange(box.MinZ, 19.5, 20.5); // основание поднято на 20 мм + var v = (await _query.GetPartInfoAsync()).Volume; + Assert.InRange(v, Math.PI * 25 * 10 * 0.95, Math.PI * 25 * 10 * 1.05); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Loft_two_squares_makes_solid() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Два квадратных сечения 20×20 на XOY (Z=0) и на XOY+40 → призма 20×20×40. + var s1 = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s1, -10, -10, 10, 10); + await _modeler.CloseSketchAsync(s1); + var s2 = await _modeler.OpenSketchOnOffsetPlaneAsync(BasePlane.XOY, offset: 40); + await _modeler.AddRectangleAsync(s2, -10, -10, 10, 10); + await _modeler.CloseSketchAsync(s2); + + var id = await _modeler.LoftAsync(new[] { s1, s2 }); + Assert.True(id > 0); + await _modeler.RebuildAsync(); + + var v = (await _query.GetPartInfoAsync()).Volume; // 20×20×40 = 16000 + Assert.InRange(v, 16000 * 0.9, 16000 * 1.1); + } + finally { await _docs.CloseAsync(save: false); } + } }