docs: спек assembly_add_component — учтены 8 замечаний ревью Codex

Проверки COM-результатов (AddFromFile/SetOrigin/UpdatePlacement), helper
RequireActiveAssembly (раздельные ошибки, проверка типа документа до TopPart),
валидация расширения .m3d/.a3d + нормализация пути, before/after count в тесте,
фиксированная геометрия детали от нуля, проверка сохранённости файла.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 12:57:04 +03:00
parent a8f7363869
commit 5f17884f06
@@ -1,7 +1,7 @@
# Дизайн: вставка компонента в сборку (assembly_add_component) через API7
**Дата:** 2026-05-27
**Статус:** спроектировано (сигнатуры подтверждены рефлексией interop + kompas-sdk-research); на ревью (Codex)
**Статус:** спроектировано (сигнатуры подтверждены рефлексией interop + kompas-sdk-research); ревью Codex учтено (8 замечаний внесены)
## Цель
@@ -34,20 +34,34 @@ COM-QI, операции на STA-потоке `KompasDispatcher`) и **новы
- `IPlacement3D.SetOrigin(double X, double Y, double Z)`.
```
IKompasDocument3D doc3d = (IKompasDocument3D)app.ActiveDocument; // должна быть СБОРКА
// валидация: ((IKompasDocument)doc3d).DocumentType == ksDocumentAssembly
IPart7 top = (IPart7)doc3d.TopPart;
IParts7 parts = top.Parts; // COM-свойство
// 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 = (IPart7)parts.AddFromFile(filePath, /*ExternalFile*/ true, /*Redraw*/ true);
IPart7 part = parts.AddFromFile(filePath, /*ExternalFile*/ true, /*Redraw*/ true) as IPart7
?? throw; // проверяем результат
// ExternalFile=true: компонент — ссылка на внешний .m3d (что нам и нужно — отдельный файл-источник).
// Redraw=true: перестроить сразу (отдельный RebuildModel не нужен).
// Redraw=true: вставить и перерисовать ИСХОДНОЕ положение (в начало координат сборки).
IPlacement3D pl = (IPlacement3D)part.Placement; // read-only свойство, мутируем
pl.SetOrigin(x, y, z); // мировые координаты сборки, мм
part.UpdatePlacement(/*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` требует получить
@@ -74,12 +88,24 @@ origin. `AddFromFile` + `Placement.SetOrigin` + `UpdatePlacement` — миним
## Валидация (чистые static, unit-тест)
`AssemblyValidation`:
- `RequireComponentPath(string? path)` — путь непустой (формат строки).
- `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` с путём.
Проверка существования файла (`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 сборки».
## Реализация
@@ -92,19 +118,24 @@ origin. `AddFromFile` + `Placement.SetOrigin` + `UpdatePlacement` — миним
## Тестирование
### Unit (`AssemblyValidationTests`)
- `RequireComponentPath` бросает на null/пустой/пробельный; пропускает валидный.
- `NormalizeComponentPath` бросает на null/пустой/пробельный; бросает на чужом расширении
(`.step`/`.txt`); пропускает `.m3d`/`.a3d` (в т.ч. с пробелами и в разном регистре), возвращает
абсолютный путь.
- `RequireFinitePosition` бросает на NaN/Infinity по каждой координате; пропускает конечные.
### Integration (`AssemblyTests`, Collection KompasCollection)
Паттерн: построить деталь → `SaveAsAsync(.scratch/*.m3d)``CloseAsync` → создать сборку
`AddComponentAsync` → проверить → `finally CloseAsync`.
Паттерн: построить деталь → `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. **Вставка в начало координат:** деталь-коробка 40×40×20 (эскиз 0..40 × 0..40, выдавить 20),
сохранить, закрыть. Создать сборку, `AddComponentAsync(file, 0,0,0)`.
- `list_components``Count == 1`; компонент: `SizeX≈40, SizeY≈40, SizeZ≈20` (±допуск).
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 подтверждает работу позиции).
- `get_bounding_box` сборки: X∈[50,90] (сдвиг origin на +50 подтверждает работу позиции).
Объёмом сборку тоже можно проверить (`get_part_info` на TopPart агрегирует компоненты) —
но габарит надёжнее и прямо проверяет позицию. Артефакты `.m3d` — в gitignored `.scratch/`.