# Высокоуровневое API

Обменная модель (`ExchangeModel`): вы описываете, что должно оказаться в проекте Evos, а Evos сам
решает, что создать, а что обновить. Берите это API, когда данные приходят из внешней системы —
своего каталога, импорта, генератора.

Путь данных всегда один и тот же:

```
собрать модель  →  (опционально) сериализовать  →  опубликовать
IExchangeModelBuilder   IExchangeModelSerializer    в репозиторий или на сервер
```

Сборки: `Evos.Api.Model` (модель, семейства, сериализация), `Evos.Api.Common.Families`
(готовые каталожные семейства), `Evos.Api.CollaborationRepository` (публикация и сервер).

Рабочие примеры в `Evos.Api.Samples`: `BuildExchangeModelSample`, `GraphicsSample`,
`CustomFamilySample`, `ValidationErrorsSample`, `SerializeExchangeModelSample`,
`PublishToRepositorySample`, `SystemsAndLevelsSample`, `CollaborationSample`.

## Собрать модель

```csharp
var builder = ExchangeModelBuilderFactory.Create(projectId);

var pump = builder.NewProduct();
pump.KeyProperties.SetValue(CommonFamilies.КаталогПроизводитель.Производитель, "Grundfos");
pump.KeyProperties.SetValue(CommonFamilies.КаталогПроизводитель.АртикулПроизводителя, "UPS-25");
pump.Properties.SetValue(EvosFamilies.ProductName.Name, "Насос циркуляционный");

OperationResult<IReadOnlyExchangeModel> result = builder.Build();
```

`IExchangeModelBuilder` умеет четыре вещи: `NewProduct()`, `NewSystem(name, color)`,
`NewLinearProduct(segmentGraphicsProduct, length)` и `GraphicsBuilder` для геометрии.
`Build()` валидирует модель и отдаёт `IReadOnlyExchangeModel`. **Вызывать его можно один раз** —
повторный вызов бросит исключение.

### Продукт — это тип, экземпляр — размещение

Прежде чем про свойства: `IExchangeProduct` описывает **класс изделия**, а не отдельную железку
в проекте. «Лампа» — продукт; четыре лампы в люстре — четыре `IExchangeProductInstance` одного
продукта «Лампа» внутри продукта «Люстра». Свойства, заданные продукту, — общие для всех его
экземпляров; индивидуальное у экземпляра — то, что задано на самом экземпляре.

```csharp
var lamp = builder.NewProduct();                    // тип: «Лампа»
var chandelier = builder.NewProduct();              // тип: «Люстра»

var instance = chandelier.NewProductInstance(lamp); // лампа, размещённая в люстре
```

Каталог из десяти позиций — это десять продуктов. Сборка из ста одинаковых болтов — это один
продукт «Болт» и сто экземпляров.

### Ключевые свойства задают тождество

Это главное правило всего API. Два продукта с одинаковым набором **ключевых** свойств
(`KeyProperties`) — для Evos один и тот же продукт, то есть один и тот же тип. Обычные свойства
(`Properties`) в тождество не входят: имя, матрица, расчётные величины — это атрибуты, а не
идентичность.

Отсюда следствие, на котором спотыкаются: **экземплярам продукта нужен собственный идентификатор**.
Экземпляры по определению ссылаются на один и тот же продукт и различаются только именем
и матрицей, то есть обычными свойствами, — и без своего ключа `Build()` сочтёт их дублями:

```csharp
for (var i = 0; i < 3; i++)
{
    var instance = assembly.NewProductInstance(part);

    instance.KeyProperties.SetValue(GraphicFamilies.ModelObjectId.ObjectId, Guid.NewGuid());

    instance.Properties.SetValue(EvosFamilies.ProductName.Name, $"Деталь {i + 1}");
    instance.Properties.SetValue(EvosFamilies.SpaceGraphicModel.Transformation, matrix);
}
```

`ProjectId` в ключевые свойства экземпляров модель проставляет сама при `Build()` — руками его
добавлять не надо.

#### Служебный ключ «ID проекта»

Билдер кладёт **каждому продукту** служебное ключевое свойство «ID проекта» — вы его не задавали,
но в `KeyProperties` оно есть. **Ключом продукта оно не считается.**

Так и должно быть: по служебному ключу продукт совпал бы с любым другим продуктом, у которого нет
собственных ключей, — и на публикации перезаписал бы его. Поэтому `Build()` требует **собственных**
ключевых свойств и без них возвращает `MissingProductKeyProperties`. Задайте их — обычно это
производитель плюс артикул (`CommonFamilies.КаталогПроизводитель`). Исключение — продукт с `ModelId`:
у него есть адрес, ключи ему не нужны (см. ниже).

Спросить об этом можно и самому — расширениями `ExchangeProductExtensions`:

```csharp
product.HasModelId();            // продукт адресован напрямую
product.HasOwnKeyProperties();   // есть ключи, заданные вами (служебный не в счёт)
```

Отсюда же разница с `IsEmpty()`: контейнер `KeyProperties` продукта **не пуст**, даже если вы
не задали ни одного ключа — в нём лежит служебный. «Контейнер пуст» и «ключей нет» — разные вопросы.

#### Коллекция в роли ключа не работает

Значения ключевых свойств ссылочных типов сравниваются **по ссылке**, а не по содержимому.
Два разных `List<int>` с одинаковыми элементами — разные ключи, и продукты с ними дубликатами
не считаются. В ключевые свойства кладите скаляры: строки, числа, `Guid`, единицы измерения,
перечисления.

### Экземпляры, системы, линейные продукты

**Экземпляр** — вхождение одного продукта в другой: `product.NewProductInstance(other)`.
У экземпляра есть две необязательные ссылки:

```csharp
instance.System = system;                 // IExchangeSystem — система, к которой он относится
instance.Level = level.LevelInstanceId;   // уровень, на котором он стоит
```

`Level` — не число и не имя, а ссылка на уровень, уже существующий в проекте. Руками её не
конструируют: её отдаёт чтение проекта — `GetLevels(...)` возвращает `EvosLevelLocation.LevelInstanceId`
(см. ниже).

**Система** — продукт с именем и цветом подсветки, наследует `IExchangeProduct`:

```csharp
var system = builder.NewSystem("П1 Приточная", Color.FromSRgb(0, 200, 0, 255, false));

var ductInstance = section.NewProductInstance(duct);
ductInstance.System = system;                   // в системе состоит вхождение, а не тип
```

Создать систему мало — к ней надо привязать оборудование, и привязывают именно **экземпляр**:
в системе состоит конкретный воздуховод в конкретном месте проекта, а не «воздуховод вообще».
См. `SystemsAndLevelsSample`.

**Имя и цвет системы — её ключевые свойства**, то есть её тождество. Опубликуете ту же систему
с другим цветом — в проекте появится **вторая** система, а не перекрашенная первая.

**Уровень** отдельным типом в API не представлен: уровень — это продукт, которому задали свойства
семейства `EvosFamilies.Level`, а уровнем проекта становится его **экземпляр** внутри другого
продукта (здания, секции):

```csharp
var level = builder.NewProduct();
level.Properties.SetValue(EvosFamilies.Level.Label, "1");
level.Properties.SetValue(EvosFamilies.Level.StoreyName, "Первый этаж");

var levelInstance = section.NewProductInstance(level);   // вот это и есть уровень проекта
```

**Категоризация** решает, куда продукт ляжет в дереве менеджера проекта. Разделитель вложенности —
обратный слэш:

```csharp
duct.Properties.SetValue(EvosFamilies.Categorization.Catalog, @"Каталожные\Вентиляция\Воздуховоды");
duct.Properties.SetValue(EvosFamilies.Categorization.ProductKind, ProductKind.Cataloged);
```

**Линейный продукт** — воздуховод, труба, лоток: графический сегмент, растиражированный по длине.
Ему нужен графический продукт-сегмент и длина:

```csharp
var segment = builder.GraphicsBuilder.GetOrCreateGraphicProduct(mesh);
var duct = builder.NewLinearProduct(segment, Length.CreateOrThrow(ValueObjectsAccess.Meter.FromMeter(3.5)));
```

### Графика

Геометрия — треугольные меши (`MeshGraphic`: вершины, индексы, цвет). Работать с ними —
через `builder.GraphicsBuilder`:

| Метод | Когда |
|---|---|
| `SetProductMesh(mesh, product)` | Положить меш прямо в продукт |
| `GetOrCreateGraphicProduct(mesh)` | Получить продукт с этим мешем, переиспользуя уже созданный при совпадении геометрии |
| `AddGraphicToProductAsInstance(product, mesh)` | Добавить продукту меш отдельным экземпляром |
| `AddGraphicParts(generalGraphicProduct, graphic)` | Разложить части графики экземплярами внутри общего графического продукта |

Меш кладут на продукт-деталь один раз — экземпляры переиспользуют его геометрию.
Рабочий пример со всеми четырьмя методами — `GraphicsSample`.

### Две ловушки GraphicsBuilder

**Цвет не входит в хэш геометрии.** Продукты переиспользуются по вершинам и граням, а цвет —
обычное свойство. Второй вызов `GetOrCreateGraphicProduct` с той же геометрией другого цвета
не создаст новый продукт, а **перекрасит существующий** — вместе со всеми, кто на него ссылается:

```csharp
var grey = graphics.GetOrCreateGraphicProduct(triangle(Grey));
var red  = graphics.GetOrCreateGraphicProduct(triangle(Red));   // это тот же продукт, теперь красный
```

Нужны две одинаковые железки разного цвета — делайте их экземплярами
(`AddGraphicToProductAsInstance`): там цвет лежит в ключевых свойствах экземпляра и различает их.

**`SetProductMesh` не регистрирует продукт для переиспользования.** Он кладёт геометрию в готовый
продукт, но в словарь мешей билдера его не заносит. Последующий `GetOrCreateGraphicProduct`
с той же геометрией о нём не узнает и заведёт **второй** продукт с тем же мешем.

**Геометрия входит в тождество продукта.** И `SetProductMesh`, и `GetOrCreateGraphicProduct` кладут
продукту **ключевое** свойство — хэш меша. Значит, изменив геометрию изделия, вы меняете его
тождество: на публикации появится **новый** продукт, а не обновлённый старый. Если геометрия у вас
меняется, а изделие остаётся тем же, — держите её на отдельном графическом продукте и подключайте
экземпляром (`AddGraphicToProductAsInstance`).

Класть меш свойством напрямую (`EvosFamilies.StaticGraphicCompressible.Mesh`) тоже можно, но это
свойство принимает `CompressedMesh`, а не `MeshGraphic`: сжимать пришлось бы самому. Пользуйтесь
билдером.

## Свойства и семейства

Свойство всегда принадлежит семейству и адресуется через его описание — не строкой, а типизированным
`IExchangePropertyDescription<T>`, поэтому тип значения проверяется компилятором:

```csharp
product.Properties.SetValue(EvosFamilies.ProductName.Name, "Насос");     // string
product.Properties.SetValue(EvosFamilies.Level.AheadX, length);          // Length
```

Читают тем же описанием. `GetValue` бросает `KeyNotFoundException`, если значение не задано, —
для неуверенного чтения есть `TryGetValue` и `HasValue`:

```csharp
if (product.Properties.TryGetValue(EvosFamilies.ProductName.Name, out var name))
{
    // …
}
```

Готовые семейства собраны в трёх статических реестрах:

| Реестр | Сборка | Что в нём |
|---|---|---|
| `EvosFamilies` | `Evos.Api.Model` | Предопределённые семейства Evos: `ProductName`, `Level`, `Building`, `Room`, `SpaceGraphicModel`, `System`, оборудование вентиляции, виды и шаблоны |
| `CommonFamilies` | `Evos.Api.Common.Families` | Каталожные семейства: `КаталогПроизводитель`, `КаталогМатериал`, `КаталогРазмерыДиаметр`, степени защиты, электрика, фитинги |
| `GraphicFamilies` | `Evos.Api.Model` | Служебные графические: `ModelObjectId`, `ProjectGuid`, `GraphicHashCode`, `LinearProductLength` |

Типы значений — типы системы Evos: `string`, `bool`, `int`, `double`, `Guid`, единицы измерения
(`Length`, `Distance`, `Area`, `Power`, `Voltage`, `Temperature`, …), геометрия (`TransformationMatrix`,
`Position2D`, `Vector3`, `BoundingBox3D`), `Color` и перечисления (`CustomEnumValue`).

Свойство-перечисление хранится как `CustomEnumValue`, а допустимые значения перечислены
в его описании (`RangeValidValues`):

```csharp
product.Properties.SetValue(CommonFamilies.КаталогМатериал.Материал, new CustomEnumValue("Сталь нержавеющая"));
```

### Убрать семейство у продукта

`product.ExcludeFamilyDescription(family)` — обратная операция: она не просто пропускает семейство
при сборке модели, а **удаляет его у продукта в Evos** при публикации, вместе со всеми значениями.
Пользуйтесь осознанно: это единственная операция высокоуровневого API, которая забирает данные,
а не добавляет.

Исключать семейство, свойства которого лежат в `KeyProperties`, нельзя — это ломает тождество
продукта, и `Build()` вернёт `ExcludedFamilyInKeyProperties`.

### Своё семейство

Если готовых не хватает — опишите своё: класс наследует `FamilyDefinitionBase`, помечается
`[FamilyDefinitionId]` со **стабильным** GUID (по нему Evos узнаёт семейство между публикациями),
в конструкторе вызывает `Initialize()`, а свойства объявляет как `IExchangePropertyDescription<T>`
со стабильным идентификатором:

```csharp
[FamilyDefinitionId("B677F925-E007-4647-98AC-637D700DB156")]
public sealed class МоёСемействоDefinition : FamilyDefinitionBase
{
    public МоёСемействоDefinition() => Initialize();

    protected override string FamilyName => "Моё семейство";

    public IExchangePropertyDescription<string> Артикул { get; } = new ExchangePropertyDescription<string>(
        "03F947CD-551B-484F-86BC-435DE1AAC872",  // идентификатор свойства
        "Артикул",                               // имя
        "Мои свойства",                          // категория
        "Артикул по каталогу поставщика");       // пояснение

    public IExchangePropertyEnumDescription<МойEnum> Исполнение { get; } = new ExchangePropertyEnumDescription<МойEnum>(
        "CE9A42B6-5906-4541-843D-261EA95BBB4C", "Исполнение", "Мои свойства", "");
}
```

Для enum-свойства значения перечисления задают атрибутом `[CustomEnumValue("…")]` на членах enum —
именно эта строка уедет в Evos.

### Адресация к тому, что уже есть в проекте: ModelId

Обычно продукт находят по ключевым свойствам. Но если продукт **уже существует в проекте** и вы
хотите не создать похожий, а работать именно с ним, — его адресуют `ModelId`, полученным чтением
проекта:

```csharp
var levels = collaboration.GetLevels(location).ValueOrThrow();
var level = levels.First(l => l.Path == "Здание - Этаж 1 (Первый этаж)");

// продукт-контейнер, внутри которого лежит этот уровень
var floor = builder.NewProduct();
floor.ModelId = level.ContainerId;               // адрес существующего продукта

// оборудование кладём экземплярами внутрь него и ставим на уровень
var pumpInstance = floor.NewProductInstance(pump);
pumpInstance.KeyProperties.SetValue(GraphicFamilies.ModelObjectId.ObjectId, objectId);
pumpInstance.Level = level.LevelInstanceId;
```

Это и есть основной сценарий `ModelId`: подключиться к существующему контейнеру проекта, а не
плодить рядом свой. **Продукт с `ModelId` ключевых свойств иметь не должен** — у него уже есть
адрес, тождество по ключам для него не считается. Нарушение поймает `Build()`.

Два идентификатора легко перепутать, а означают они разное:

| Тип | Что адресует | Откуда берётся |
|---|---|---|
| `EvosModelId` | Продукт (его модель в репозитории) | `EvosLevelLocation.ContainerId`, `EvosProductStructure.ProductId` |
| `EvosInstanceRef` | Конкретный объект вместе с контекстом вложенности — например, уровень внутри здания | `EvosLevelLocation.LevelInstanceId` |

Оба — **непрозрачные строки: их не конструируют руками**, а получают чтением проекта и кладут
обратно как есть.

**Важное ограничение.** Выдаёт эти идентификаторы только чтение проекта **с Collaboration-сервера**
(`GetLevels`, `GetProductStructure`). Из локального репозитория, открытого низкоуровневой фабрикой,
получить `EvosModelId` или `EvosInstanceRef` публичным API нечем: сериализаторы идентификаторов
внутренние. Поэтому и адресация по `ModelId`, и привязка экземпляра к уровню показаны
в `CollaborationSample`, а не в локальных сэмплах.

Если сервера нет, а привязать экземпляр к уровню надо, — это делают низкоуровневым API,
присоединяемым семейством `IProductCurrentLevel` (см. `RepositoryEditSample`).

## OperationResult

Все операции, которые могут не сработать по данным (сборка, публикация, чтение с сервера),
возвращают `OperationResult<T>` — вместо исключений. `Unit` в `OperationResult<Unit>` означает
«результата нет, важен сам факт успеха».

```csharp
var result = builder.Build();

if (!result.TryGetValue(out var model))
{
    foreach (var error in result.Errors)   // только записи уровня Error
    {
        Console.WriteLine(error);
    }
    return;
}
```

| Член | Что даёт |
|---|---|
| `Succeeded` | Успех (в том числе с предупреждениями) |
| `TryGetValue(out value)` | Успех и значение одним вызовом |
| `ValueOrThrow()` | Значение или исключение — годится, когда отказ не предусмотрен |
| `Diagnostics` / `Errors` | Диагностика целиком / только ошибки |
| `Exception` | Непредвиденное исключение, если оно было (`ResultState.Faulted`) |

### Что проверяет Build()

| Проверка | Когда падает |
|---|---|
| `MissingProductKeyProperties` | У продукта нет ни `ModelId`, ни собственных ключевых свойств — найти его в репозитории нечем |
| `PropertyIsBothKeyAndNonKey` | Одно и то же свойство задано и в `KeyProperties`, и в `Properties` одного контейнера |
| `DuplicateProduct` | Два продукта с одинаковыми ключевыми свойствами |
| `DuplicateInstance` | Два экземпляра одного продукта с одинаковыми ключевыми свойствами (тот самый забытый `ModelObjectId`) |
| `ProductWithModelIdHasKeyProperties` | У продукта есть и `ModelId`, и свои ключевые свойства |
| `DuplicateModelId` | Один `ModelId` у двух продуктов |
| `ExcludedFamilyInKeyProperties` | В ключевых свойствах семейство, исключённое у продукта |

Ошибки приходят в `Diagnostics` как `ValidationErrorOutputPayload` с `ExchangeModelValidationError`:
в нём вид ошибки и контейнеры, на которых она возникла.

## Сериализация

Формат бинарный (protobuf), поэтому модель отдают файлом или потоком.

```csharp
ExchangeModelSerializerFactory.CreateSerializer().Serialize(model, filePath);
var restored = ExchangeModelSerializerFactory.CreateDeserializer().Deserialize(filePath);
```

Обе операции есть и для `Stream` — так модель отдают в память или в сеть. Сериализация нужна только
для обмена: если модель уже в памяти, публиковать её можно напрямую.

Сериализатор оставляет поток **в конце** — перед чтением его перематывают на начало, иначе
десериализатор увидит пустоту:

```csharp
serializer.Serialize(model, stream);
stream.Position = 0;
var restored = deserializer.Deserialize(stream);
```

## Публикация в локальный репозиторий

```csharp
var exporter = ExchangeModelToEvosExporterFactory.Create();

OperationResult<Unit> result = exporter.Export(repository, model);
```

`repository` — тот самый `IConcurrentRepository` из низкоуровневого API. Отдельного «репозитория
обменной модели» не существует: репозиторий открывают низкоуровневой фабрикой и **его же** отдают
экспортёру.

```csharp
using var repository = new ApiConcurrentRepositoryFactory().OpenNewRepository(location);

ExchangeModelToEvosExporterFactory.Create().Export(repository, model);
```

Рядом есть `ExportFromStream(repository, stream)` и `ExportFromFile(repository, path)` — для
сериализованной модели. Вся связка целиком — в `PublishToRepositorySample`.

## Идемпотентность, стабильные идентификаторы и удаление

**Продукт** из модели ищется в репозитории по `ModelId`, а если его нет — по ключевым свойствам.
Нашёлся — обновляется, не нашёлся — создаётся.

**Экземпляр** ищется среди вложенных экземпляров уже найденного продукта — но не среди всех, а
только среди тех, что помечены **тем же `projectId`**, с которым собрана модель. Внутри этого
множества экземпляр опознаётся по своим ключевым свойствам (обычно `ModelObjectId`).

Отсюда два следствия, о которых легко не подумать.

### Идентификаторы обязаны быть стабильными между публикациями

`projectId` в `ExchangeModelBuilderFactory.Create(projectId)` и `ModelObjectId` у экземпляров —
это **идентификаторы объектов вашей системы**, а не случайные значения на каждый запуск. Если
подставить `Guid.NewGuid()`, то при следующей публикации той же модели Evos не узнает ни проект,
ни экземпляры — и создаст их заново. Продукты при этом сойдутся по ключевым свойствам, а
экземпляры удвоятся.

Берите то, что в вашей системе не меняется: идентификатор проекта и идентификатор объекта.
Так делает импорт из CAD — `projectId` берётся из проекта, `ModelObjectId` из идентификатора
объекта чертежа.

### Экспорт удаляет экземпляры, которых нет в модели

Публикация — не «добавить», а **привести в соответствие**. Экземпляры продукта, помеченные тем же
`projectId`, но отсутствующие в публикуемой модели, из репозитория **удаляются**.

Это то, что нужно при повторном импорте (объект убрали из чертежа — он должен уйти и из проекта),
но это же означает: **публикуйте проект целиком, а не по частям**. Модель, в которую попала
половина объектов проекта, вторую половину удалит.

Экземпляров с другим `projectId` это не касается — чужие данные в проекте не пострадают.

## Collaboration-сервер

`ICollaborationRepository` работает с проектом на Collaboration-сервере: сам подтягивает проект
в локальный кэш, вливает изменения и отправляет обратно.

Проект и ветка должны существовать на сервере заранее — создавать их API не умеет.

```csharp
var collaboration = new CollaborationRepositoryBuilder()
    .UseLogger(logger)                              // ICollaborationLogger, необязательно
    .UseRepositoryCachePathProvider(pathProvider)   // куда класть локальный кэш, необязательно
    .Build();

var location = new RepositoryLocation(serverUrl, projectName, branchName);
```

Без `UseLogger` и `UseRepositoryCachePathProvider` берутся значения по умолчанию.

### Посмотреть, что уедет на сервер

`AddUncommittedChangesProcessor(processor)` — точка расширения: обработчик вызывают **перед
коммитом**, когда модель уже влита в локальный кэш, но на сервер ещё ничего не ушло. Ему отдают
`IConcurrentRepository`, и он может прочитать незакоммиченные изменения низкоуровневым API:

```csharp
public OperationResult<Unit> Process(IConcurrentRepository repository)
{
    using var vcsRead = repository.VCSReadConnection();

    var report = new UncommittedChangesReportDto();
    foreach (var transaction in vcsRead.LocalUncommittedChanges())
    {
        report.TotalCount += transaction.Operations.Count;
        // …
    }

    var diagnostics = new List<OperationOutputEntry>();
    diagnostics.Report(report, OutputLevel.Info);   // полезной нагрузкой, а не текстом

    return diagnostics.ToOperationResult();
}
```

Всё, что обработчик вернёт, попадёт в `Diagnostics` результата `ImportAndPush` — там отчёт
и разбирают. Отдавайте его **полезной нагрузкой** (`UncommittedChangesReportDto`), а не строкой:
тогда его можно обработать программно, а не парсить текст. Рабочий пример — `CollaborationSample`.

### Писать

```csharp
collaboration.ImportAndPush(location, model);    // или поток с сериализованной моделью
```

`ImportAndPush` делает всё разом: подтягивает проект с сервера, вливает в него модель, коммитит
и пушит обратно. Если после импорта менять оказалось нечего, коммит не создаётся.
`SyncRepository(location)` — просто синхронизировать локальный кэш, ничего не публикуя.

### Читать

```csharp
var levels = collaboration.GetLevels(location).ValueOrThrow();
foreach (var level in levels)
{
    Console.WriteLine($"{level.Path} → {level.LevelInstanceId}");   // этот id и кладут в instance.Level
}

var structure = collaboration.GetProductStructure(location, "Сборка").ValueOrThrow();
// structure.Buildings → уровни → помещения; structure.ProductId — это EvosModelId продукта
```

Каждый вызов сам синхронизирует локальный кэш с сервером. Если продукта с таким именем в проекте
нет — это **отказ**, а не пустой результат: `Succeeded` будет `false`, а причина придёт в
`Errors`. Когда отсутствие продукта — штатная ветка, разбирайте результат через `TryGetValue`.

Если готовых читателей не хватает, откройте сессию и прочитайте репозиторий своим `IEvosReader<T>` —
он получает низкоуровневый read-доступ:

```csharp
using var session = collaboration.OpenReadSession(location).ValueOrThrow();
var names = session.Read(new ProductNamesReader());

private sealed class ProductNamesReader : IEvosReader<List<string>>
{
    public List<string> Read(IEvosReadContext context)
    {
        return context.ReadAccess.GetProducts()
            .Select(product => product.GetPredefinedFamilyOrThrow<IProductName>().Facade().Name)
            .ToList();
    }
}
```

`IEvosReadContext.ReadAccess` — это `IRepositoryReadAccess` из низкоуровневого API, так что внутри
читателя доступна вся модель репозитория. `session.TryRead(reader)` — то же самое, но отказ приходит
в `OperationResult`, а не исключением.

## Частые ошибки

**Экземпляры без собственного идентификатора.** Самая частая: `Build()` падает с `DuplicateInstance`,
потому что имя и матрица — обычные свойства и в тождество не входят. Ставьте
`GraphicFamilies.ModelObjectId.ObjectId`.

**Случайные `projectId` и `ModelObjectId`.** `Guid.NewGuid()` на каждый запуск ломает идемпотентность:
экземпляры удвоятся при следующей публикации. Идентификаторы берут из своей системы — они обязаны
быть теми же самыми.

**Публикация проекта по частям.** Экспорт приводит проект в соответствие с моделью, а не дополняет
его: экземпляры с тем же `projectId`, которых в модели нет, будут удалены.

**Повторный `Build()`.** Билдер одноразовый: собрали модель — заводите новый билдер.

**Проигнорированный `OperationResult`.** Ни `Build()`, ни `Export`, ни `ImportAndPush` не бросают
исключение на ошибке данных — они возвращают отказ с диагностикой. Проверяйте `Succeeded`
или зовите `ValueOrThrow()`.

**Нестабильный `FamilyDefinitionId`.** GUID семейства — его имя для Evos. Сгенерировали новый —
получили новое семейство и потеряли связь со старыми данными.

**`Level` как строка или номер.** Это ссылка на существующий уровень проекта, её берут из `GetLevels`.

**Продукт без своих ключевых свойств.** `MissingProductKeyProperties`: служебный ключ «ID проекта»
за ключ не считается, различать продукты нечем. Задайте производителя и артикул.

**Одно свойство и в `KeyProperties`, и в `Properties`.** `PropertyIsBothKeyAndNonKey`. Публикация
пишет сначала ключевые свойства, потом обычные, поэтому обычное значение затрёт ключевое: тождество
продукта в Evos разойдётся с тем, что заявила модель, и следующая публикация его не найдёт.
Свойство живёт либо в ключах, либо в атрибутах — не в обоих.

**Созданная, но ни к чему не привязанная система.** `NewSystem` заводит систему; в проекте она
останется пустой, пока экземплярам не проставят `instance.System`.

**Один и тот же меш разного цвета.** `GetOrCreateGraphicProduct` вернёт один продукт и перекрасит
его — цвет геометрию не различает.

**Поток, не перемотанный на начало** перед `Deserialize` — после `Serialize` он стоит в конце.
