feat(feature): операция Ребро жёсткости (rib)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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` (толщина в прямом направлении нормали плоскости).
|
||||
@@ -230,6 +230,41 @@ public sealed partial class PartModeler
|
||||
return id;
|
||||
}, ct);
|
||||
|
||||
/// <summary>Построить ребро жёсткости от разомкнутого контура эскиза до тела.
|
||||
/// thickness — толщина стенки (мм); side — направление достройки (left|right|up|down);
|
||||
/// symmetric — толщина симметрична плоскости эскиза; angle — уклон стенок (градусы).
|
||||
/// Возвращает id операции.</summary>
|
||||
public Task<int> 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);
|
||||
|
||||
/// <summary>Перестроить документ.</summary>
|
||||
public Task RebuildAsync(CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
|
||||
@@ -9,6 +9,17 @@ public static class SketchGeometry
|
||||
/// <summary>describe для ksRegularPolygon: вписанный (вершины на окружности) → false; описанный → true.</summary>
|
||||
public static bool PolygonDescribe(bool inscribed) => !inscribed;
|
||||
|
||||
/// <summary>Направление достройки ребра жёсткости (ksRibSideEnum): left=0, right=1, up=2, down=3.</summary>
|
||||
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)),
|
||||
};
|
||||
|
||||
/// <summary>Требовать строго положительное значение (радиус, полуось).</summary>
|
||||
public static void RequirePositive(double value, string paramName)
|
||||
{
|
||||
|
||||
@@ -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<string> 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<string> Rebuild()
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ArgumentException>(() => SketchGeometry.RibSide("diagonal"));
|
||||
|
||||
[Fact]
|
||||
public void RequirePositive_throws_on_zero_or_negative()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user