From fc99739e1e16dffc588c661fd0adc00fc5ef79ff Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Tue, 26 May 2026 22:51:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(feature):=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=A0=D0=B5=D0=B1=D1=80=D0=BE=20=D0=B6?= =?UTF-8?q?=D1=91=D1=81=D1=82=D0=BA=D0=BE=D1=81=D1=82=D0=B8=20(rib)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RibAsync (o3d_ribOperation): SetSketch + index/angle/side + SetThinParam. SketchGeometry.RibSide маппинг (left/right/up/down). Инструмент rib в FeatureTools. Эмпирически: контур ребра висит в зазоре (не касается тела), side=left для косынки. Integration: L-уголок + ребро в углу → объём растёт. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-26-rib-design.md | 70 +++++++++++++++++++ .../Modeling/PartModeler.Features.cs | 35 ++++++++++ .../Modeling/SketchGeometry.cs | 11 +++ src/Kompas.Mcp.Host/Tools/FeatureTools.cs | 9 +++ .../Integration/FeatureOpsTests.cs | 33 +++++++++ tests/Kompas.Mcp.Tests/SketchGeometryTests.cs | 13 ++++ 6 files changed, 171 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-rib-design.md diff --git a/docs/superpowers/specs/2026-05-26-rib-design.md b/docs/superpowers/specs/2026-05-26-rib-design.md new file mode 100644 index 0000000..560436d --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-rib-design.md @@ -0,0 +1,70 @@ +# Дизайн: пакет B (шаг 2) — операция «Ребро жёсткости» (rib) + +**Дата:** 2026-05-26 +**Статус:** на ревью (автономный режим; ревьюер — Codex) + +## Цель + +Добавить формообразующую операцию **«Ребро жёсткости»** (rib) — достроить тонкую стенку от +разомкнутого контура эскиза до тела. Второй шаг пакета B. Один MCP-инструмент `rib`. + +## Сигнатуры COM (рефлексия interop + SDK-база + ksConstants3D.h) + +``` +Obj3dType.o3d_ribOperation = 44 +ksRibDefinition: + int index // сегмент эскиза, задающий уклон (0 — первый сегмент) + double angle // угол уклона стенок, градусы (0 — вертикальные) + int side // направление достройки: ksRibSideEnum + bool SetSketch(object) / GetSketch() + bool SetThinParam(short thinType, double normalThickness, double reverseThickness) +ksRibSideEnum: ksRibSideLeft=0, ksRibSideRight=1, ksRibSideUp=2, ksRibSideDown=3 +Direction_Type (thinType): dtNormal=0, dtReverse=1, dtBoth=2, dtMiddlePlane=3 +``` + +**Паттерн:** `NewEntity(o3d_ribOperation)` → `GetDefinition()` → `SetSketch` → +`index`/`angle`/`side`/`SetThinParam` → `Create()`. Порядок: сначала `SetSketch`, затем +параметры, затем `Create()`. + +**Эскиз:** разомкнутый контур (отрезок/ломаная) на плоскости, пересекающей тело; КОМПАС +сам «дотягивает» ребро до тела. + +## Новый MCP-инструмент (группа Feature) + +| Инструмент | Параметры | Поведение | +|---|---|---| +| `rib` | `sketchId: int`, `thickness: double`, `side: string = "up"`, `symmetric: bool = true`, `angle: double = 0` | Построить ребро жёсткости от разомкнутого контура эскиза `sketchId` до тела. `thickness` — толщина стенки (мм). `side` ∈ {left,right,up,down} — направление достройки. `symmetric=true` — толщина симметрична плоскости эскиза; `false` — в одну сторону. `angle` — уклон стенок (°). | + +**Маппинги:** +- `side`: `left→0, right→1, up→2, down→3` (через `SketchGeometry.RibSide(string)`, unit-тест). +- `symmetric=true → SetThinParam(dtBoth=2, thickness/2, thickness/2)`; + `false → SetThinParam(dtNormal=0, thickness, 0)`. +- `index = 0` (фиксировано — первый сегмент контура). + +## Реализация + +- **Метод** `RibAsync(int sketchId, double thickness, string side, bool symmetric, double angle, CancellationToken)` + в `PartModeler.Features.cs`. Эскиз закрывается перед операцией (`CloseSketchCore`, как + `ExtrudeAsync`/`RevolveAsync`); берётся `sketch.Entity` из реестра по id. Возвращает id операции. +- **Маппинг side** — `SketchGeometry.RibSide(string)` (public static, unit-тест; бросает + `ArgumentException` на неизвестное значение). +- **Инструмент** `rib` в `FeatureTools.cs`. +- **Валидация** (inline): `thickness > 0`; `side` — известное значение (через `RibSide`). + `Create()==FALSE` → `InvalidOperationException` (контур не пересекает тело / некорректное + направление?). +- Транзитный `ksRibDefinition` не освобождается — консистентно с прочими операциями (долг v2-2). + +## Тестирование + +**Unit:** `SketchGeometry.RibSide` — маппинг строк в коды и бросок на неизвестном значении. + +**Integration** (`FeatureOpsTests`): Г-образный уголок (L-профиль на XOZ, замкнутая ломаная +`(0,0)(40,0)(40,5)(5,5)(5,40)(0,40)`, extrude по Y на 30 → объём 375·30=11250) → эскиз ребра +на XOZ: разомкнутый отрезок-диагональ во внутреннем углу (например `(25,10)→(10,25)`, **висит в зазоре, не касаясь полок**) → +`RibAsync(sketchId, thickness=5, side="left", symmetric=false)` → объём увеличивается +(добавлена косынка): `after > before`, прирост правдоподобный (`after ∈ (before, before·1.3)`). + +**Проверено эмпирически (важно для контракта):** контур ребра должен **висеть в зазоре** +между полками (не касаться граней тела концами — иначе `Create()==FALSE`); КОМПАС сам +дотягивает полотно до тела в направлении `side`. Для косынки во внутреннем углу сработало +`side="left"`, `symmetric=false` (толщина в прямом направлении нормали плоскости). diff --git a/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs b/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs index 08359ac..39be616 100644 --- a/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs +++ b/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs @@ -230,6 +230,41 @@ public sealed partial class PartModeler return id; }, ct); + /// Построить ребро жёсткости от разомкнутого контура эскиза до тела. + /// thickness — толщина стенки (мм); side — направление достройки (left|right|up|down); + /// symmetric — толщина симметрична плоскости эскиза; angle — уклон стенок (градусы). + /// Возвращает id операции. + public Task RibAsync(int sketchId, double thickness, string side = "up", bool symmetric = true, double angle = 0, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + if (thickness <= 0) throw new ArgumentOutOfRangeException(nameof(thickness), "Толщина должна быть > 0."); + var sideCode = SketchGeometry.RibSide(side); + var sketch = RequireSketch(sketchId); + CloseSketchCore(sketch); // эскиз должен быть закрыт перед операцией + + var part = GetTopPart(); + var entity = part.NewEntity((short)Obj3dType.o3d_ribOperation) as ksEntity + ?? throw new InvalidOperationException("NewEntity(o3d_ribOperation) вернул null."); + var def = entity.GetDefinition() as ksRibDefinition + ?? throw new InvalidOperationException("GetDefinition() ребра вернул не ksRibDefinition."); + def.SetSketch(sketch.Entity); + def.index = 0; // первый сегмент контура задаёт уклон + def.angle = angle; + def.side = sideCode; + if (symmetric) + def.SetThinParam((short)Direction_Type.dtBoth, thickness / 2, thickness / 2); + else + def.SetThinParam((short)Direction_Type.dtNormal, thickness, 0); + + if (!entity.Create()) + throw new InvalidOperationException( + "Create() ребра вернул FALSE (контур не пересекает тело или неверное направление side?)."); + + 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/SketchGeometry.cs b/src/Kompas.Mcp.Core/Modeling/SketchGeometry.cs index 8fd736b..e849f38 100644 --- a/src/Kompas.Mcp.Core/Modeling/SketchGeometry.cs +++ b/src/Kompas.Mcp.Core/Modeling/SketchGeometry.cs @@ -9,6 +9,17 @@ public static class SketchGeometry /// describe для ksRegularPolygon: вписанный (вершины на окружности) → false; описанный → true. public static bool PolygonDescribe(bool inscribed) => !inscribed; + /// Направление достройки ребра жёсткости (ksRibSideEnum): left=0, right=1, up=2, down=3. + public static short RibSide(string side) => side?.Trim().ToLowerInvariant() switch + { + "left" => 0, + "right" => 1, + "up" => 2, + "down" => 3, + _ => throw new ArgumentException( + $"Неизвестное направление ребра: '{side}'. Ожидается left|right|up|down.", nameof(side)), + }; + /// Требовать строго положительное значение (радиус, полуось). public static void RequirePositive(double value, string paramName) { diff --git a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs index 6b74327..9fd020b 100644 --- a/src/Kompas.Mcp.Host/Tools/FeatureTools.cs +++ b/src/Kompas.Mcp.Host/Tools/FeatureTools.cs @@ -104,6 +104,15 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler) return $"Оболочка толщиной {thickness} мм создана (удалено граней: {removed}), id={id}."; } + [McpServerTool(Name = "rib")] + [Description("Построить ребро жёсткости от разомкнутого контура эскиза до тела. sketchId — эскиз с разомкнутым контуром (отрезок/ломаная) на плоскости, пересекающей тело. thickness — толщина стенки ребра (мм). side — направление достройки до тела: left|right (вдоль плоскости эскиза) или up|down (ортогонально плоскости). symmetric=true — толщина симметрична плоскости эскиза, false — в одну сторону. angle — уклон стенок (градусы). Возвращает id операции.")] + public async Task Rib(int sketchId, double thickness, string side = "up", bool symmetric = true, double angle = 0) + { + await session.ConnectAsync(); + var id = await modeler.RibAsync(sketchId, thickness, side, symmetric, angle); + return $"Ребро жёсткости толщиной {thickness} мм создано (side={side}), 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 c102d9d..5b919f5 100644 --- a/tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs +++ b/tests/Kompas.Mcp.Tests/Integration/FeatureOpsTests.cs @@ -48,4 +48,37 @@ public sealed class FeatureOpsTests } finally { await _docs.CloseAsync(save: false); } } + + [Fact] + public async Task Rib_adds_material_in_corner_of_L_bracket() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Г-образный уголок: L-профиль на XOZ (замкнутая ломаная), выдавить по Y на 30. + // Площадь L = 40×5 + 5×35 = 375 → объём 375·30 = 11250. + var sBody = await _modeler.OpenSketchAsync(BasePlane.XOZ); + var lprofile = new (double x, double y)[] { (0, 0), (40, 0), (40, 5), (5, 5), (5, 40), (0, 40) }; + await _modeler.AddPolylineAsync(sBody, lprofile, closed: true); + await _modeler.CloseSketchAsync(sBody); + await _modeler.ExtrudeAsync(sBody, depth: 30); + var before = (await _query.GetPartInfoAsync()).Volume; + Assert.True(before > 0); + + // Ребро-косынка: разомкнутый отрезок-диагональ во внутреннем углу, на XOZ (Y=0). + // side=up (ортогонально, +Y), несимметрично → растёт от Y=0 вглубь тела на 5 мм. + var sRib = await _modeler.OpenSketchAsync(BasePlane.XOZ); + await _modeler.AddLineAsync(sRib, 25, 10, 10, 25); // висит в пустом углу, не касаясь полок + await _modeler.CloseSketchAsync(sRib); + var id = await _modeler.RibAsync(sRib, thickness: 5, side: "left", symmetric: false); + Assert.True(id > 0); + await _modeler.RebuildAsync(); + + // Косынка добавляет материал: объём растёт, но в разумных пределах. + var after = (await _query.GetPartInfoAsync()).Volume; + Assert.True(after > before, "После ребра жёсткости объём должен увеличиться."); + Assert.InRange(after, before, before * 1.3); + } + finally { await _docs.CloseAsync(save: false); } + } } diff --git a/tests/Kompas.Mcp.Tests/SketchGeometryTests.cs b/tests/Kompas.Mcp.Tests/SketchGeometryTests.cs index 7921ee7..75631c6 100644 --- a/tests/Kompas.Mcp.Tests/SketchGeometryTests.cs +++ b/tests/Kompas.Mcp.Tests/SketchGeometryTests.cs @@ -17,6 +17,19 @@ public sealed class SketchGeometryTests public void PolygonDescribe_inverts_inscribed(bool inscribed, bool expected) => Assert.Equal(expected, SketchGeometry.PolygonDescribe(inscribed)); + [Theory] + [InlineData("left", 0)] + [InlineData("right", 1)] + [InlineData("up", 2)] + [InlineData("down", 3)] + [InlineData("UP", 2)] + public void RibSide_maps_known(string side, int expected) + => Assert.Equal(expected, (int)SketchGeometry.RibSide(side)); + + [Fact] + public void RibSide_throws_on_unknown() + => Assert.Throws(() => SketchGeometry.RibSide("diagonal")); + [Fact] public void RequirePositive_throws_on_zero_or_negative() {