# Дизайн: пакет C — массивы и зеркало (linear/circular pattern, mirror)

**Дата:** 2026-05-26
**Статус:** на ревью (автономный режим; ревьюер — Codex)

## Цель

Добавить класс операций «размножение»: **линейный массив** (по сетке вдоль координатной
оси), **круговой массив** (вокруг координатной оси) и **зеркальное отражение** (операций
относительно координатной плоскости; и тела целиком). Самый ценный недостающий класс
(NEXT-SESSION приоритет 1). Реализуется тремя инкрементами, по одной операции за раз.

Все три операции в API5 строятся одним паттерном: `NewEntity(o3d_*)` → `GetDefinition()` →
задать **источник** (коллекция копируемых операций / тел) + **направление** (ось/плоскость) →
`Create()` → регистрация в `_features`. Источник копирования — `ksEntity` ранее созданных
операций, на которые ссылаемся по их id из реестра `_features` (те же id, что возвращают
extrude/revolve/fillet/…).

## Сигнатуры COM (рефлексия interop + справка SDK)

```
Obj3dType.o3d_meshCopy        = 35   // массив по сетке (линейный)
Obj3dType.o3d_circularCopy    = 36   // круговой массив
Obj3dType.o3d_mirrorOperation = 48   // зеркальная копия выбранных операций
Obj3dType.o3d_mirrorAllOperation = 49 // зеркально отразить тело(а) целиком
Obj3dType.o3d_axisOX/OY/OZ    = 71/72/73  // координатные оси (GetDefaultEntity)
Obj3dType.o3d_planeXOY/XOZ/YOZ = 1/2/3    // координатные плоскости

ksMeshCopyDefinition:                              // линейный (по сетке)
    bool   SetAxis1(object axisOrEdge)             // ось 1 (коорд. ось или прямое ребро)
    bool   SetAxis2(object axisOrEdge)             // ось 2 (для 2D-сетки)
    bool   SetCopyParamAlongAxis(bool firstAxis, double angle, int count, double step, bool factor)
    int    count1/count2; double step1/step2; double angle1/angle2; bool factor1/factor2
    bool   geomArray; bool insideFlag
    object OperationArray()                         // ksEntityCollection источников

ksCircularCopyDefinition:                          // круговой
    bool   SetAxis(object axis)                     // ось вращения
    bool   SetCopyParamAlongDir(int count, double step, bool factor, bool dir)
    int    count1/count2; double step1/step2; bool factor1/factor2; bool geomArray; bool inverce
    object GetOperationArray()                      // ksEntityCollection источников

ksMirrorCopyDefinition:                            // зеркало выбранных операций
    bool   SetPlane(object plane)                   // плоскость симметрии (коорд. или плоская грань)
    object GetOperationArray()                      // ksEntityCollection источников

ksMirrorCopyAllDefinition:                          // зеркало тела(а) целиком
    bool   SetPlane(object plane)
    object ChooseBodies()                            // ksChooseBodies (необяз.: все тела по умолчанию)
```

**Семантика (из справки, проверить эмпирически):**
- `count` — количество **всех** экземпляров, **включая исходный** (count=3 → +2 копии).
- линейный `step` — мм между **соседними** при `factor=false`; круговой `step` — **угол в
  градусах** между соседними при `factor=false`.
- `count` включает оригинал → требуем `count >= 2`.

## Новые MCP-инструменты (группа Feature)

| Инструмент | Параметры | Поведение |
|---|---|---|
| `linear_pattern` | `featureIds: int[]`, `axis: "X"\|"Y"\|"Z"`, `count: int`, `step: double`, `geometric=false` | Линейный массив: размножить операции `featureIds` вдоль координатной оси `axis`, `count` экз. всего (вкл. исходную, ≥2), шаг `step` мм между соседними. `geometric` → быстрое геом. копирование. Возвращает id. |
| `circular_pattern` | `featureIds: int[]`, `axis: "X"\|"Y"\|"Z"`, `count: int`, `step=` (угол, °), `reverse=false`, `geometric=false` | Круговой массив вокруг координатной оси `axis`, `count` экз. всего (≥2), угловой шаг `step` ° между соседними (по умолчанию 360/count для равномерного — см. ниже). `reverse` — обратное направление. Возвращает id. |
| `mirror_operation` | `featureIds: int[]`, `plane: "XOY"\|"XOZ"\|"YOZ"` | Зеркальная копия операций `featureIds` относительно координатной плоскости `plane`. Возвращает id. |
| `mirror_body` | `plane: "XOY"\|"XOZ"\|"YOZ"` | Зеркально отразить всё тело относительно плоскости (получить симметричную деталь). Возвращает id. |

**Решения (YAGNI / инкрементальность):**
- **Ось/плоскость — только координатные** (X/Y/Z, XOY/XOZ/YOZ) на первом этапе: детерминированно,
  легко тестируется, покрывает большинство случаев. Выбор по ребру/грани — отдельная задача позже.
- **Линейный массив — 1D** (одна ось): `SetAxis1` + `SetCopyParamAlongAxis(true,…)`, `count2=1`
  (второе направление выключено). 2D-сетку добавим позже при необходимости.
- `factor=false` (шаг между соседними — интуитивнее для пользователя).
- **Круговой — задаём свойства напрямую** (`SetCopyParamAlongDir` неоднозначен: `dir`/radial vs
  annular): `count1=1` (радиально — один «ряд»), `count2=count` (кольцевое — N вокруг оси),
  `step2=step` (°), `factor2=false`, `inverce=reverse`. Угловой шаг задаёт агент (например 90).
- `mirror_body` использует `ksMirrorCopyAllDefinition` + `SetPlane`. В API5-interop у определения
  **нет** `SaveInitialObjects` (по рефлексии — только `ChooseBodies/Get/SetPlane`); поведение
  «оригинал + зеркало» проверяем эмпирически. `ChooseBodies` дорефлексировать в инкременте 3
  (для односложного тела, вероятно, не требуется; если `Create` упадёт — выставить `ksAllBodies`).
- `geomArray` дефолт `false` (полное копирование операции — надёжнее для несложных тел).

### Открытые API-тонкости (проверяются интеграционными тестами)
- `count` включает оригинал — подтверждается тестом (count=3 → 3 экз.). Если нет — поправить.
- `count2=1` действительно выключает 2-е направление линейного массива.
- Круговой: `count2=count`/`count1=1` даёт чистый круговой массив (без радиального дубля).
- `mirror_body` оставляет исходное тело (а не заменяет его).

## Реализация

- **Enum** `CoordinateAxis { X, Y, Z }` + `CoordinateAxes.ToObj3dType/Parse` — новый файл
  `Core/Modeling/CoordinateAxis.cs` (по аналогии с `BasePlane.cs`).
- **Методы** в `PartModeler.Features.cs`:
  - `LinearPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool geometric, CancellationToken)`
  - `CircularPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool reverse, bool geometric, CancellationToken)`
  - `MirrorOperationAsync(IReadOnlyList<int> featureIds, BasePlane plane, CancellationToken)`
  - `MirrorBodyAsync(BasePlane plane, CancellationToken)`
- **Хелпер** `RequireFeature(int id)` в `PartModeler.cs` (по аналогии с `RequireSketch`):
  `_features.TryGetValue` → `ksEntity` или `KeyNotFoundException`.
- **Валидация** (чистая, `SketchGeometry`): `RequireMin(int value, int min, string)` для `count>=2`;
  `RequirePositive(step, …)` (ловит NaN). Списки `featureIds`: not null, `Distinct`, не пуст,
  все id существуют (проверка до мутации модели — собрать `ksEntity` заранее).
- **Инструменты** в `FeatureTools.cs`: `linear_pattern`, `circular_pattern`, `mirror_operation`,
  `mirror_body`; `axis`/`plane` парсятся из строки (`CoordinateAxes.Parse`/`BasePlanes.Parse`).
- Имена методов коллекции в interop различаются: MeshCopy — `OperationArray()`; Circular и
  Mirror — `GetOperationArray()` (по рефлексии). Все → `as ksEntityCollection ?? throw`.
- Транзитные RCW не освобождаем точечно — консистентно (долг v2-2).
- `Create()==FALSE` → `InvalidOperationException` с подсказкой (несовместимая геометрия / шаг
  ведёт к самопересечению / неверная ось).

## Тестирование (Integration, `FeatureOpsTests`)

Паттерн: `CreateAsync(Part)` → построить базовую операцию (запомнить её id) → массив/зеркало →
`RebuildAsync` → проверить объём (`GetPartInfoAsync`) и габарит (`GetBoundingBoxAsync`); `finally CloseAsync`.

1. **linear_pattern**: цилиндр R5×H10 в центре (V₁≈785 мм³) → `linear_pattern([cyl], X, 3, 30)` →
   3 непересекающихся цилиндра → V≈3·785=2356 (`InRange ±5%`); габарит X≈70 (от −5 до 65).
2. **circular_pattern**: цилиндр R3×H10 со смещением (центр (20,0)) → `circular_pattern([cyl], Z, 3, 90)` →
   3 цилиндра в (20,0),(0,20),(−20,0) (count=3, несимметрично → осмысленный поворот, не «полный
   круг») → V≈3·283=849 (`±5%`); габарит X≈46 (от −23 до 23), SizeY≈26 (от −3 до 23).
3. **mirror_operation** (две исходные операции — проверяет заполнение `ksEntityCollection`):
   цилиндр R3 центр (20,0) + цилиндр R2 центр (10,0), оба H10 → `mirror_operation([c1,c2], YOZ)` →
   копии в (−20,0),(−10,0) → V≈2·(283+125.7)=817 (`±5%`); габарит X≈46.
4. **mirror_body**: цилиндр R3×H10 центр (20,0) → `mirror_body(YOZ)` → оригинал + зеркало в
   (−20,0) → V≈2·283=565 (`±5%`); габарит X≈46.

Ожидаемые объёмы считаем аналитически, сверяем `InRange ±5%`. Открытые тонкости (count вкл.
оригинал; count2=1; круговой count1=1/count2=N; mirror_body оставляет оригинал) — проверяются
этими тестами; при расхождении корректируем по факту, сохранив суть проверки.
