# Дизайн: вставка компонента в сборку (assembly_add_component) через API7

**Дата:** 2026-05-27
**Статус:** реализовано и проверено (3 интеграционных теста зелёные, 136 всего); ревью Codex спека учтено (8 замечаний)

## Эмпирическая находка при реализации (важно — не разучивать)

`part.UpdatePlacement(true)` возвращает **FALSE** при перемещении вручную позиционируемого
компонента (без сопряжений) — это **НЕ ошибка** (нечего пересчитывать от зависимостей). Само
перемещение задаёт `placement.SetOrigin(x,y,z)` (живой объект Placement, не копия — проверено:
сдвиг origin на +50 даёт габарит сборки X[50,90]), а **видимым в геометрии** оно становится после
`top.RebuildModel(true)`. Поэтому: результат `SetOrigin` проверяем (FALSE = ошибка), результат
`UpdatePlacement` **игнорируем**, обязателен `RebuildModel(true)`. Разфиксация первого компонента
(`Fixed=false`) **НЕ нужна** — зафиксированный КОМПАСом первый компонент всё равно перемещается
через `SetOrigin`+`RebuildModel` (проверено: оба теста — origin и offset — зелёные без снятия
фиксации).

## Цель

Открыть новый класс функционала — **СБОРКИ**. До сих пор сделана только геометрия ДЕТАЛИ.
Первый инкремент вехи: **вставить деталь из сохранённого `.m3d`-файла в активную сборку** с
заданием положения. Это фундамент — без вставки компонентов сборка пуста.

`list_components` (чтение сборки) уже есть; теперь учимся её НАПОЛНЯТЬ.

## Архитектурное решение

Сборки — это **НЕ `PartModeler`** (он строит API5-деталь `ksPart`). Заводим **новый сервис
`AssemblyService`** (API7, паттерн как у `HoleService`/`FaceEditService`: контейнеры берём
COM-QI, операции на STA-потоке `KompasDispatcher`) и **новый класс инструментов `AssemblyTools`**.

- `src/Kompas.Mcp.Core/Assemblies/AssemblyService.cs` — сервис.
- `src/Kompas.Mcp.Core/Assemblies/AssemblyValidation.cs` — чистые static-валидаторы (unit-тест).
- `src/Kompas.Mcp.Core/Assemblies/AddedComponent.cs` — record результата (Index, Name).
- `src/Kompas.Mcp.Host/Tools/AssemblyTools.cs` — MCP-инструмент.
- DI: `AddSingleton<AssemblyService>()` в `Program.cs`.

(Namespace `Kompas.Mcp.Core.Assemblies` — во множественном числе, чтобы не конфликтовать с
`System.Reflection.Assembly`.)

## Подтверждённый workflow (рефлексия interop + справка)

Рефлексией `KompasAPI7.dll` подтверждено:
- `IParts7.AddFromFile(string FileName, bool ExternalFile, bool Redraw)` → `Part7`.
- `IPart7.Placement` (`Placement3D`), `IPart7.UpdatePlacement(bool Redraw)`.
- `IPlacement3D.SetOrigin(double X, double Y, double Z)`.

```
// RequireActiveAssembly (см. ниже) — раздельные проверки до доступа к TopPart:
IKompasDocument doc = app.ActiveDocument;                          // != null
// doc.DocumentType == ksDocumentAssembly  (деталь тоже IKompasDocument3D — отсечь по типу!)
IKompasDocument3D doc3d = (IKompasDocument3D)doc;
IPart7  top   = (IPart7)doc3d.TopPart;                             // != null
IParts7 parts = top.Parts;

IPart7 part = parts.AddFromFile(filePath, /*ExternalFile*/ true, /*Redraw*/ true) as IPart7
    ?? throw;   // проверяем результат
// ExternalFile=true: компонент — ссылка на внешний .m3d (что нам и нужно — отдельный файл-источник).
// Redraw=true: вставить и перерисовать ИСХОДНОЕ положение (в начало координат сборки).

IPlacement3D pl = part.Placement as IPlacement3D ?? throw;         // read-only свойство, мутируем
if (!pl.SetOrigin(x, y, z)) throw;                                 // мировые координаты сборки, мм
if (!part.UpdatePlacement(/*Redraw*/ true)) throw;                 // именно это ПРИМЕНЯЕТ перемещение
```

**Проверки результатов (как в `HoleService`):** `AddFromFile` приводится к `IPart7` (иначе ошибка);
`Placement` приводится к `IPlacement3D`; `SetOrigin(...)==true`; `UpdatePlacement(true)==true`.
Неуспех любого — `InvalidOperationException` с понятным текстом. (Откат «осиротевшего» компонента
при неуспехе позиционирования — best-effort через `part.Owner?.Delete()`; на практике origin-сдвиг
надёжен, но проверки обязательны.)

**Redraw ≠ применение перемещения (важно):** `AddFromFile(..., Redraw:true)` вставляет и
перерисовывает компонент в ИСХОДНОМ положении. Само перемещение применяет и проверяет именно
`part.UpdatePlacement(true)` — не `Redraw`. Отдельный `doc3d.RebuildDocument()` в базовом workflow
не нужен (интеграционный тест подтверждает габарит после `UpdatePlacement`).

**Почему `AddFromFile`, а не `AddFromFileWithParam`:** для первого инкремента нужен только сдвиг
origin. `AddFromFile` + `Placement.SetOrigin` + `UpdatePlacement` — минимальный надёжный путь
(подтверждён рефлексией: все три члена есть в interop). `AddFromFileWithParam` требует получить
`IPlacement3D`/`IInsertPartParameters` через `IKompasDocument1.GetInterface(код)` до вставки — это
лишняя неочевидность (коды 11223/13196 не проверены рефлексией) и YAGNI на этом шаге. Ориентацию
(`SetVector`/углы Эйлера) и параметры вставки добавим в следующих инкрементах при необходимости.

**Единицы:** `x/y/z` — миллиметры в мировой СК сборки. По умолчанию (0,0,0) компонент встаёт в
начало координат сборки.

**Фиксация:** через API первый компонент НЕ фиксируется автоматически. На этом шаге фиксацию НЕ
выставляем (YAGNI) — для теста объёмом/габаритом она не нужна; добавим параметр `fixed` позже,
если понадобится.

## MCP-инструмент (новая группа Assembly)

| Инструмент | Параметры | Поведение |
|---|---|---|
| `assembly_add_component` | `filePath: string`, `x=0`, `y=0`, `z=0` | Вставить деталь/подсборку из `.m3d`/`.a3d`-файла в активную сборку; origin компонента — (x,y,z) мм. Возвращает индекс и имя вставленного компонента. |

Контракт: активный документ должен быть **сборкой** (иначе понятная ошибка); файл должен
существовать. Возврат — текст вида «Компонент '<имя>' вставлен в сборку, индекс N, позиция (x,y,z)».

## Валидация (чистые static, unit-тест)

`AssemblyValidation`:
- `NormalizeComponentPath(string? path) → string` — `Trim` → проверка непустоты → проверка
  расширения (`.m3d`/`.a3d`, case-insensitive; иначе `ArgumentException`) → `Path.GetFullPath`
  (вернуть абсолютный путь). Чистая (не трогает ФС) → unit-тест.
- `RequireFinitePosition(double x, double y, double z)` — координаты конечны (`double.IsFinite`;
  отсекает `Infinity`/`NaN`, как в `HoleService`).

Проверка существования файла (`File.Exists` на нормализованном пути) — в сервисе (не чистая, не
unit-тест), даёт `FileNotFoundException` с абсолютным путём.

## Проверка активного документа (helper в сервисе)

`RequireActiveAssembly()` — раздельные ошибки (Codex #2):
1. `ActiveDocument == null` → «Нет активного документа».
2. `doc.DocumentType != ksDocumentAssembly` → «Активный документ не сборка (тип: …). Создайте
   сборку: document_create assembly». (Деталь — тоже `IKompasDocument3D`, поэтому проверка по типу
   обязательна ДО доступа к `TopPart`.)
3. `doc is not IKompasDocument3D` → «Активный документ не 3D».
4. `doc3d.TopPart is not IPart7` → «Не удалось получить TopPart сборки».

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

- `AssemblyService.AddComponentAsync(string filePath, double x, double y, double z, ct)` —
  на STA-потоке: валидация → проверка типа активного документа (сборка) → `AddFromFile` →
  `Placement.SetOrigin` → `UpdatePlacement` → вернуть `AddedComponent(Index, Name)`.
  Индекс = `parts.Count - 1` после вставки; имя = `part.Name`.
- Транзитные RCW не освобождаем точечно — консистентно с остальным слоем (долг v2-2).

## Тестирование

### Unit (`AssemblyValidationTests`)
- `NormalizeComponentPath` бросает на null/пустой/пробельный; бросает на чужом расширении
  (`.step`/`.txt`); пропускает `.m3d`/`.a3d` (в т.ч. с пробелами и в разном регистре), возвращает
  абсолютный путь.
- `RequireFinitePosition` бросает на NaN/Infinity по каждой координате; пропускает конечные.

### Integration (`AssemblyTests`, Collection KompasCollection)
Паттерн: построить деталь → `SaveAsAsync(.scratch/*.m3d)` → **проверить `File.Exists` + размер>0** →
`CloseAsync` → создать сборку → `AddComponentAsync` → проверить → `finally CloseAsync`.
**Геометрия детали фиксирована от нуля** (эскиз 0..40 × 0..40, выдавить 0..20 ⇒ локальный габарит
X[0,40] Y[0,40] Z[0,20]) — чтобы абсолютные ожидания по габариту были детерминированы (Codex #7).

1. **Вставка в начало координат:** сохранить деталь-коробку, проверить файл, закрыть. Создать
   сборку. `before = list_components.Count`. `var c = AddComponentAsync(file, 0,0,0)`.
   - `list_components.Count == before + 1` (Codex #6); компонент по индексу `c.Index`:
     `SizeX≈40, SizeY≈40, SizeZ≈20` (±допуск); `c.Name` непуст.
   - `get_bounding_box` сборки: X∈[0,40], Y∈[0,40], Z∈[0,20] (±допуск).
2. **Позиционирование:** та же деталь, `AddComponentAsync(file, 50, 0, 0)`.
   - `get_bounding_box` сборки: X∈[50,90] (сдвиг origin на +50 подтверждает работу позиции).

Объёмом сборку тоже можно проверить (`get_part_info` на TopPart агрегирует компоненты) —
но габарит надёжнее и прямо проверяет позицию. Артефакты `.m3d` — в gitignored `.scratch/`.

## Дальнейшие инкременты (вне этого спека)
2. `assembly_add_mate` — сопряжения (`IMateConstraints3D.Add(mc_Coincidence/mc_Distance/…)` +
   `BaseObject1/2` + `Update`). Типы: совпадение/параллельность/перпендикулярность/касательность/
   концентричность/расстояние/угол/симметрия.
3. Перемещение/массив/фиксация компонентов — по необходимости.
