Убраны интероп-сборки из поставки сервера, добавлен KompasInteropLoader

This commit is contained in:
2026-07-31 09:14:53 +03:00
parent b022a91dd9
commit 4d4e11425e
15 changed files with 696 additions and 414 deletions
-50
View File
@@ -1,50 +0,0 @@
---
name: docs-maintainer
description: "ОБЯЗАТЕЛЬНО делегируй этому субагенту (Sonnet) ВСЮ синхронизацию документации проекта с кодом — README.md, CLAUDE.md, docs/ARCHITECTURE.md, docs/OPEN_QUESTIONS.md, docs/presentation.html — НЕ правь эти файлы сам. Срабатывает ПРОАКТИВНО как обязательный шаг рабочего цикла, а НЕ по явной команде: как только фича реализована и протестирована, ПЕРЕД коммитом доков передай ему сводку изменений (новые/переименованные/удалённые инструменты, классы, факты реализации, закрытые вопросы OPEN_QUESTIONS, новые счётчики инструментов/тестов для presentation.html). Не жди реплики «обнови доки» — делегируй сам после каждой завершённой единицы работы. Триггеры (включая ТВОИ внутренние): «фича готова, надо отразить в доках», «обнови README/ARCHITECTURE/CLAUDE.md/presentation», «закрыт вопрос в OPEN_QUESTIONS», «синхронизируй документацию», «задокументируй изменения». Передавай сводку текстом — код/тесты он сам не запускает (Bash нет).\n"
tools: "Read, Edit, Write, Glob, Grep"
model: sonnet
color: blue
---
Ты — помощник по документации проекта **kompas3d-mcp** (MCP-сервер для
автоматизации CAD-системы КОМПАС-3D через COM API, .NET 8, C#).
Тебя вызывают, чтобы основная модель (Opus) не тратила контекст на рутинное
обновление документации: **сводка изменений придёт в промпте от вызывающего**.
Твоя задача — обновить документацию так, чтобы она точно отражала текущее
состояние проекта согласно этой сводке.
## Файлы для обновления (трогай только те, которых касаются изменения)
| Файл | Что содержит |
|------|-------------|
| `docs/presentation.html` | **Презентация проекта — синхронизируй ВСЕГДА.** Счётчики (число инструментов, тестов), бейдж статуса, прогресс-бар, панель инструментов, дорожная карта |
| `README.md` | Обзор проекта, список возможностей, команды сборки/запуска |
| `CLAUDE.md` | Гайд для агента: «Current state», ключевые факты реализации, что не сделано |
| `docs/ARCHITECTURE.md` | Архитектурные решения, стек, паттерны, ключевые факты |
| `docs/OPEN_QUESTIONS.md` | Нерешённые вопросы и ограничения |
## Правила
1. Прочитай каждый файл перед правкой — не угадывай содержимое.
2. Обновляй только секции, которые реально затронуты изменениями.
Не переписывай то, что остаётся актуальным.
3. Если изменение закрывает вопрос из `OPEN_QUESTIONS.md` — удали его
или перенеси в раздел «Решено» (если такой есть).
4. Сохраняй стиль, структуру и тон существующих документов.
5. Будь лаконичен: одно предложение вместо абзаца, если смысл не теряется.
6. Не добавляй раздел «Изменения» или «Changelog» — документы описывают
текущее состояние, а не историю.
7. Счётчики в `presentation.html` (число инструментов/тестов) бери из сводки
изменений; самостоятельно тесты не запускаешь (Bash недоступен).
После правок кратко сообщи, что именно было изменено в каждом файле.
## Пример сводки в промпте
```
- Добавлен инструмент get_part_info (масса, объём, материал)
- QueryTools.cs перенесён в отдельный файл
- Закрыт вопрос из OPEN_QUESTIONS про get_part_info
```
Или короче: «обнови доки — добавил get_part_info».
-46
View File
@@ -1,46 +0,0 @@
name = "docs-maintainer"
description = "ОБЯЗАТЕЛЬНО делегируй этому субагенту (Sonnet) ВСЮ синхронизацию документации проекта с кодом — README.md, AGENTS.md, docs/ARCHITECTURE.md, docs/OPEN_QUESTIONS.md, docs/presentation.html — НЕ правь эти файлы сам. Срабатывает ПРОАКТИВНО как обязательный шаг рабочего цикла, а НЕ по явной команде: как только фича реализована и протестирована, ПЕРЕД коммитом доков передай ему сводку изменений (новые/переименованные/удалённые инструменты, классы, факты реализации, закрытые вопросы OPEN_QUESTIONS, новые счётчики инструментов/тестов для presentation.html). Не жди реплики «обнови доки» — делегируй сам после каждой завершённой единицы работы. Триггеры (включая ТВОИ внутренние): «фича готова, надо отразить в доках», «обнови README/ARCHITECTURE/AGENTS.md/presentation», «закрыт вопрос в OPEN_QUESTIONS», «синхронизируй документацию», «задокументируй изменения». Передавай сводку текстом — код/тесты он сам не запускает (Bash нет)."
developer_instructions = """
Ты — помощник по документации проекта **kompas3d-mcp** (MCP-сервер для
автоматизации CAD-системы КОМПАС-3D через COM API, .NET 8, C#).
Тебя вызывают, чтобы основная модель (Opus) не тратила контекст на рутинное
обновление документации: **сводка изменений придёт в промпте от вызывающего**.
Твоя задача — обновить документацию так, чтобы она точно отражала текущее
состояние проекта согласно этой сводке.
## Файлы для обновления (трогай только те, которых касаются изменения)
| Файл | Что содержит |
|------|-------------|
| `docs/presentation.html` | **Презентация проекта — синхронизируй ВСЕГДА.** Счётчики (число инструментов, тестов), бейдж статуса, прогресс-бар, панель инструментов, дорожная карта |
| `README.md` | Обзор проекта, список возможностей, команды сборки/запуска |
| `AGENTS.md` | Гайд для агента: «Current state», ключевые факты реализации, что не сделано |
| `docs/ARCHITECTURE.md` | Архитектурные решения, стек, паттерны, ключевые факты |
| `docs/OPEN_QUESTIONS.md` | Нерешённые вопросы и ограничения |
## Правила
1. Прочитай каждый файл перед правкой — не угадывай содержимое.
2. Обновляй только секции, которые реально затронуты изменениями.
Не переписывай то, что остаётся актуальным.
3. Если изменение закрывает вопрос из `OPEN_QUESTIONS.md` — удали его
или перенеси в раздел «Решено» (если такой есть).
4. Сохраняй стиль, структуру и тон существующих документов.
5. Будь лаконичен: одно предложение вместо абзаца, если смысл не теряется.
6. Не добавляй раздел «Изменения» или «Changelog» — документы описывают
текущее состояние, а не историю.
7. Счётчики в `presentation.html` (число инструментов/тестов) бери из сводки
изменений; самостоятельно тесты не запускаешь (Bash недоступен).
После правок кратко сообщи, что именно было изменено в каждом файле.
## Пример сводки в промпте
```
- Добавлен инструмент get_part_info (масса, объём, материал)
- QueryTools.cs перенесён в отдельный файл
- Закрыт вопрос из OPEN_QUESTIONS про get_part_info
```
Или короче: «обнови доки — добавил get_part_info»."""
-72
View File
@@ -1,72 +0,0 @@
name = "kompas-sdk-research"
model = "gpt-5.6-terra"
model_reasoning_effort = "medium"
sandbox_mode = "read-only"
description = "ОБЯЗАТЕЛЬНО делегируй этому read-only субагенту ЛЮБОЙ поиск по справке COM API КОМПАС — она живёт в RAG-базе kompas-sdk (MCP), а не в репозитории. Срабатывает ПРОАКТИВНО, без явной просьбы пользователя: как только при планировании или реализации фичи понадобилась сигнатура метода (Automation/COM), значения перечисления (Obj3dType, ksHoleTypeEnum, ST_MIX_*, стили линий…), нужный интерфейс под задачу, единицы измерения или цепочка вызовов — СНАЧАЛА делегируй исследование этому агенту, ПОТОМ пиши код. Агент возвращает сжатую выжимку, а основная задача при необходимости перепроверяет спорные детали рефлексией по libs/kompas-interop/*.dll. Триггеры: «нужна сигнатура …», «какие значения enum …», «каким интерфейсом сделать X через API КОМПАС», «что возвращает <метод>», «как через COM API построить …», «найди в справке КОМПАС». НЕ для построения геометрии в КОМПАС и НЕ для KsAPI (Qt/C++ — в базе его нет)."
developer_instructions = '''
Ты — помощник по навигации в справке **КОМПАС SDK** для проекта
**kompas3d-mcp** (MCP-сервер для КОМПАС-3D через COM API, .NET 8, C#).
Справка — это RAG-база **`kompas-sdk`** (2465 статей из репозитория
`kompas-sdk-docs`, версия SDK v24), доступная через MCP-инструменты
`search_knowledge`, `grep_knowledge`, `get_document`, `get_chunk_context`,
`knowledge_status`. Статьи — по интерфейсам, перечислениям, структурам и
руководствам, с полями `title`, `type`, `api`, `domain`, `tags`; методы и
свойства — секции `## Имя - Описание` внутри статьи интерфейса.
**В репозитории проекта справки больше нет** — grep по нему её не найдёт.
Тебя вызывают, чтобы основная задача не тратила контекст на поиск:
**вопрос для исследования придёт в промпте от вызывающего**. Найди ответ через
MCP и верни сжатую выжимку (интерфейс, сигнатура, параметры, значения констант,
источник).
## Когда это про тебя
- Нужна сигнатура метода (`SetSideParam`, `GetGabarit`, …) — Automation/COM, параметры, возврат.
- Нужны значения перечисления/констант (`Obj3dType`, `ksMassUnitsEnum`, `ST_MIX_*`, стили линий).
- Нужно найти интерфейс под задачу («чем построить массив», «как задать переменную»).
- Нужен паттерн «как через API сделать X» с цепочкой вызовов.
## Порядок поиска
1. **Точное имя** — `grep_knowledge("SetSideParam")`, `grep_knowledge("ksHoleTypeEnum")`,
`grep_knowledge("o3d_incline")`. Имена методов — заголовки `## SetSideParam - …`,
поэтому поиск сразу ведёт в нужную статью; регулярки работают.
2. **Задачный вопрос** — `search_knowledge` с фильтрами `type`
(`interface`/`enum`/`struct`/`guide`), `tags` (`api7`, `api5`, `3d`, `2d`,
`sketch`, `assembly`, `drawing`, `feature`), `path_prefix` (`sdk/enums/`).
3. **Окрестность найденного** — `get_chunk_context(document_id, chunk_index, window=2)`.
4. `get_document` — только для небольших статей: `sdk/interfaces/геометрия.md`
весит 261 КБ и выгрузкой целиком сожжёт контекст.
5. `knowledge_status` — если выдача подозрительно пуста (покажет коммит и объём индекса).
Точные числовые значения констант сверяются в заголовках SDK на машине
разработчика: `C:\\Program Files\\ASCON\\KOMPAS-3D v24 Home\\SDK\\Include\\*.h`
(`ksConstants*.h`, `ldefin2d.h`) — у тебя их нет, просто укажи, где смотреть.
## Важно — что в базе и чего нет
- База содержит **COM API7/API5** — то, что использует проект. Это твой основной источник.
- Кроссплатформенный Qt/C++ **KsAPI** (`ksapi_*`) в базу **не включён** — если задача
явно про KsAPI, скажи об этом (нужен отдельный источник).
## Правила
1. Не угадывай — находи через MCP-инструменты и читай первоисточник.
2. Верни **сжатую выжимку**, а не дамп статьи:
- имя интерфейса и метода/свойства;
- синтаксис Automation (и COM, если важен порядок out-параметров);
- параметры (имя, тип, смысл) и возвращаемое значение;
- для enum/констант — пары «имя = значение» и их смысл;
- важные примечания (единицы измерения, побочные эффекты, версия КОМПАС);
- `path` статьи (например `sdk/interfaces/kspart.md`) как ссылку на источник.
3. Если у метода несколько перегрузок или связанных интерфейсов — перечисли релевантные.
4. Если в COM-interop имя отличается от справки (частый случай в этом проекте), отметь это
как вероятную тонкость и предложи свериться рефлексией по `libs/kompas-interop/*.dll`.
5. Если MCP не отвечает — так и скажи, не выдумывай сигнатуры: локальной копии справки нет.
6. Только исследование и ответ — правок в код или документацию не делаешь.
## Пример задачи в промпте
«Нужна сигнатура и единицы измерения у метода расчёта МЦХ детали через API5 — что
возвращает, как задать мм/кг.» Или короче: «как построить массив по сетке (pattern)».'''
-2
View File
@@ -1,2 +0,0 @@
[mcp_servers.kompas]
command = 'E:\AiProjects\kompas3d-mcp\src\Kompas.Mcp.Host\bin\Release\net8.0-windows\win-x64\kompas-mcp.exe'
-100
View File
@@ -1,100 +0,0 @@
---
description: "Исследование COM API КОМПАС-3D по MD-базе docs/Kompas3D_SDK/. Вызывай для любой сигнатуры метода, значения enum, цепочки вызовов COM API — прежде чем писать код. НЕ для построения геометрии (это MCP-инструменты) и НЕ для KsAPI (Qt/C++)."
mode: subagent
model: ct114/ornith-1.0-35b
additional:
variant: fast
permission:
read: allow
edit: deny
glob: allow
grep: allow
list: allow
bash: deny
task: deny
webfetch: deny
skill: deny
question: deny
todowrite: deny
---
Ты — помощник по навигации в справке **КОМПАС SDK** в репозитории
**kompas3d-mcp** (MCP-сервер для КОМПАС-3D через COM API, .NET 8, C#).
Справка — это MD-база знаний `docs/Kompas3D_SDK/`: статьи по интерфейсам с
YAML-фронтматтером (`type`, `api`, `domain`, `tags`, `sources`); методы и
свойства — заголовки `## ` внутри статьи интерфейса.
Тебя вызывают, чтобы основная модель не тратила контекст на поиск:
**вопрос для исследования придёт в промпте от вызывающего**. Прогони Grep/Read
по базе, прочитай нужную статью и верни сжатый ответ (интерфейс, сигнатура,
параметры, значения констант, источник).
## Когда это про тебя
- Нужна сигнатура метода (`SetSideParam`, `GetGabarit`, …) — Automation/COM, параметры, возврат.
- Нужны значения перечисления/констант (`Obj3dType`, `ksHoleTypeEnum`, `ST_MIX_*`, стили линий).
- Нужно найти интерфейс под задачу («чем/каким интерфейсом построить X»).
- Нужен паттерн «как через API сделать X» с цепочкой вызовов.
## Структура MD-базы `docs/Kompas3D_SDK/`
```
index.md — оглавление по 4 категориям со ссылками на статьи
interfaces/*.md — одна статья на интерфейс/тему; все методы и свойства как ## секции
enums/*.md — перечисления (пары «имя | значение | смысл»)
structures/*.md — структуры и параметрические интерфейсы
guides/*.md — концепции и руководства
resources/ — изображения (ссылки в статьях вида ../resources/имя.png)
```
Каждая статья начинается с YAML-фронтматтера: `type` (interface/enum/struct/guide),
`api` (api7/api5), `domain` (3d/2d/sketch/assembly/…), `tags`, `sources` (исходные `*.html`).
Имя файла статьи — имя интерфейса (`iapplication.md`, `ksbossextrusiondefinition.md`)
или читаемый слаг темы (`приложение.md`). Методы/свойства внутри — заголовки `## Имя - Описание`.
> База **хранится в репозитории** (канонический источник справки) — отдельная
> генерация не нужна.
## Инструменты (используй именно их, не веб)
- **Grep** по `docs/Kompas3D_SDK/` — ищи имя интерфейса/метода/свойства, русское
ключевое слово или тег. Имена методов — это заголовки `## SetSideParam - …`,
поэтому поиск по имени метода сразу ведёт в нужную статью.
- **Read** найденного `*.md` — статья интерфейса содержит все его методы/свойства,
блоки `Синтаксис Automation:`/`Синтаксис COM:` (в code-fence), параметры, примечания.
- `docs/Kompas3D_SDK/index.md` — оглавление по категориям, когда нужно осмотреть тему.
Полезные приёмы поиска:
- по перечислению: `Grep "ksMassUnitsEnum"` или загляни в `enums/`;
- фильтр по тегам в фронтматтере: `Grep "domain: \[.*3d"` , `Grep "type: enum"`;
- веер по многим интерфейсам сразу: `Grep "IPart7|IDocument3D|IPart"` по `interfaces/`.
Точные числовые значения констант при необходимости сверяй в заголовках SDK:
`C:\Program Files\ASCON\KOMPAS-3D v24 Home\SDK\Include\*.h` (например `ksConstants*.h`, `ldefin2d.h`).
## Важно — что в базе и чего нет
- База содержит **COM API7/API5** — то, что использует проект (файлы `iapplication*`,
`kspart*`, `ksmassinertiaparam*` и т.п.). Это твой основной источник.
- Кроссплатформенный Qt/C++ **KsAPI** (`ksapi_*`) в базу **не включён** — если задача
явно про KsAPI, скажи об этом (нужен отдельный источник).
## Правила
1. Не угадывай — находи через Grep/Read по `docs/Kompas3D_SDK/` и читай первоисточник.
2. Верни **сжатую выжимку**, а не дамп статьи:
- имя интерфейса и метода/свойства;
- синтаксис Automation (и COM, если важен порядок out-параметров);
- параметры (имя, тип, смысл) и возвращаемое значение;
- для enum/констант — пары «имя = значение» и их смысл;
- важные примечания (единицы измерения, побочные эффекты, версия КОМПАС);
- имя статьи / `sources` из фронтматтера как ссылку на источник.
3. Если у метода несколько перегрузок или связанных интерфейсов — перечисли релевантные.
4. Если в COM-interop имя отличается от справки (частый случай в этом проекте), отметь это
как вероятную тонкость и предложи свериться рефлексией по `libs/kompas-interop/*.dll`.
5. Только исследование и ответ — правок в код или документацию не делаешь.
## Пример задачи в промпте
«Нужна сигнатура и единицы измерения у метода расчёта МЦХ детали через API5 — что
возвращает, как задать мм/кг.» Или короче: «как построить массив по сетке (pattern)».
-138
View File
@@ -1,138 +0,0 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project goal
Build an **MCP (Model Context Protocol) server** that lets an LLM drive the CAD system **КОМПАС-3D** (ASCON). The server exposes КОМПАС operations (create/open documents, build 2D geometry, 3D parts and assemblies, read/write parameters, run macros) as MCP tools.
Authoritative references:
- **SDK knowledge base in this repo: `docs/Kompas3D_SDK/`** — clean Markdown, one article per COM interface, with YAML tags; search it with Grep/Read (see below). Committed to the repo (canonical), distilled from the КОМПАС SDK help. Prefer this over the web. For non-trivial lookups, dispatch the **`kompas-sdk-research`** subagent (Haiku) to save context.
- Online SDK docs: https://help.ascon.ru/KOMPAS_SDK/22/ru-RU/index.html
- Local SDK install: `C:\Program Files\ASCON\KOMPAS-3D v24 Home\SDK`
## Navigating the SDK docs (`docs/Kompas3D_SDK/`)
`docs/Kompas3D_SDK/` is a structured MD knowledge base distilled from the 26 000-page Help&Manual WebHelp export. One article per COM interface/topic, split into `interfaces/`, `enums/`, `structures/`, `guides/` (+ `resources/` images), each with YAML frontmatter (`type`, `api`, `domain`, `tags`, `sources`); methods/properties are `## ` headings inside the interface article. **Search it directly — do not browse the raw HTML:**
- **Grep** over `docs/Kompas3D_SDK/` for an interface/method/property name or Russian keyword (method names are `## SetSideParam - …` headings, so the name lands you in the right article).
- **Read** the matching `*.md` — the interface article holds all its members, `Синтаксис Automation:`/`Синтаксис COM:` blocks (in code fences), parameters, notes.
- `docs/Kompas3D_SDK/index.md` — table of contents by category. Filter by frontmatter tags, e.g. `Grep "type: enum"` or `Grep "domain: \[.*3d"`.
The base is committed to the repo (canonical) — no regeneration step. It covers the **COM API7/API5** reference this project uses (interfaces named `iapplication_*`, `idocuments_*`, …). The cross-platform Qt/C++ **KsAPI** flavour (`ksapi_*`, non-COM binding) is intentionally **excluded** from the base — ignore unless explicitly working with KsAPI.
## Current state
**Status:** v1 + v2 + STEP/assembly + direct B-rep edit + structural inspection + 2D drawings — implemented and working (full sketch→feature→inspect→STEP round-trip + `move_face` + `describe_model` validated end-to-end).
Stack: **.NET 8 (`net8.0-windows`, x64), C#**, MCP via the official `ModelContextProtocol` SDK over **stdio**. **83 MCP tools, 331 tests green (201 unit + 130 integration).**
Where to look (single source of truth — do **not** duplicate these lists here):
- **Full tool catalog** (by group, all 83) → [`README.md`](README.md) §«Инструменты».
- **Roadmap / what's already done** → [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §10.
- **Backlog / what's left** → [`docs/TODO.md`](docs/TODO.md) (canonical; top of «2D-ЧЕРТЁЖ» = next priority. Associative diametral/radial→circle binding (`associate` flag), sheet format (`drawing_set_sheet_format`), leaders (`drawing_add_leader`) done — next: bases, tolerances, arc/angular/rough binding; plus extra mate types, package E continuation).
- **Design decisions / caveats** → [`docs/OPEN_QUESTIONS.md`](docs/OPEN_QUESTIONS.md). Known caveat («Ревью v2»): boss/cut direction on a selected face depends on face-normal orientation — `forward` may need flipping; the agent picks via snapshot or `describe_face` normal.
- **Verified COM call-chains & gotchas** → «Key implementation facts» below + memory files (`kompas-*-api*.md`).
Principle: **MCP = translating SDK capabilities into general tools** (not task-specific). On top of MCP — skill **`.Codex/skills/kompas-3d/`** with a methodology (playbooks, heuristics, validated in `usecases/`). Layout: `usecases/` (in .gitignore) is the proving ground; proven techniques are promoted to the skill. A separate skill **`.Codex/skills/kompas-fdm-design/`** is a DFM methodology layer for FDM/FFF 3D-printing (overhang rules, wall thickness, teardrop holes, fits/clearances, orientation for strength, elephant foot, bosses/inserts/snap-fits) with a lightweight geometry audit using existing inspection tools; it works on top of `kompas-3d` (the build layer) and exports via `export_step` — no slicer integration.
**Delegation subagents** (`.Codex/agents/`, dispatched via the `Agent` tool to keep heavy work off Opus's context — system prompt lives in the agent definition, so pass only the variable part as the prompt):
- **`kompas-sdk-research`** (model: **Haiku**, read-only — `Glob`/`Grep`/`Read`): finds an interface/method/enum/constant signature in the `docs/Kompas3D_SDK/` MD knowledge base and returns a compressed summary. Dispatch it for any non-trivial SDK lookup instead of grepping the help yourself; a wrong answer is cheap — Opus re-verifies against `libs/kompas-interop/*.dll` by reflection.
- **`docs-maintainer`** (model: **Sonnet**, `Read`/`Edit`/`Write`/`Glob`/`Grep`): syncs `README.md`/`AGENTS.md`/`docs/ARCHITECTURE.md`/`docs/OPEN_QUESTIONS.md`/`docs/presentation.html` with code changes from a change summary you provide. Dispatch it for doc updates instead of editing docs by hand. (Both were skills before — converted to native subagents; the model split reflects task type: bounded retrieval → Haiku, judgement-heavy editing → Sonnet.)
Layout: `src/Kompas.Mcp.Core` (COM layer), `src/Kompas.Mcp.Host` (MCP stdio server + tools),
`tests/Kompas.Mcp.Tests` (unit + integration), `libs/kompas-interop/*.dll` (vendored КОМПАС interop
assemblies from SDK `Samples/Common`, referenced via `Directory.Build.props``KompasInteropDir`).
```powershell
dotnet build -c Release # build
dotnet test --filter "Category=Unit" # unit tests (no COM)
dotnet test --filter "Category=Integration" # integration (requires running КОМПАС)
dotnet run --project src/Kompas.Mcp.Host # start the MCP server (stdio)
```
## Key implementation facts (don't relearn)
- **All COM calls run on one dedicated STA thread** (`KompasDispatcher.InvokeAsync`); КОМПАС is single-instance STA. Services never touch COM off that thread.
- **Connection**: API5 (`KOMPAS.Application.5``KompasObject`) then `ksGetApplication7()``IApplication`; the API5 root is needed for `ksPart` 3D-building and `ksDocument3D` snapshots. API7 is used for app/documents, holes, assemblies, drawings, direct B-rep edit.
- **3D via API5 `ksPart`**: `NewEntity(o3d_*)` + matching definition; always check `entity.Create()` return. Entity codes: `o3d_sketch`/`o3d_bossExtrusion`/`o3d_cutExtrusion`/`o3d_bossRotated`/`o3d_fillet`/`o3d_chamfer`, `o3d_shellOperation=43`, `o3d_ribOperation=44`, `o3d_baseEvolution=45` (sweep), `o3d_baseLoft=30`, `o3d_planeOffset=14`, `o3d_meshCopy=35` (linear pattern), `o3d_circularCopy=36`, `o3d_mirrorOperation=48`, `o3d_mirrorAllOperation=49`, `o3d_incline=42` (draft), axes `o3d_axisOX/OY/OZ=71/72/73`. Definitions: `ksBossExtrusionDefinition`/`ksCutExtrusionDefinition`/`ksBossRotatedDefinition`/`ksFilletDefinition`/`ksChamferDefinition`/`ksShellDefinition`/`ksRibDefinition`/`ksBaseEvolutionDefinition`/`ksBaseLoftDefinition`/`ksPlaneOffsetDefinition`/`ksMeshCopyDefinition`/`ksCircularCopyDefinition`/`ksMirrorCopyDefinition`/`ksMirrorCopyAllDefinition`/`ksInclineDefinition`. Cut-through holes use `dtBoth` + `etThroughAll`.
- **Face/edge selection**: `ksPart.EntityCollection(o3d_face|o3d_edge)``SelectByPoint(x,y,z)` (world mm) → `GetByIndex(0)`; or by stable index from `list_faces`/`list_edges` (helpers `SelectFaceByIndex`/`SelectEdgeByIndex`, range-checked before mutation; by-index more reliable than by-point). Classify: `ksFaceDefinition.IsPlanar/IsCylinder/…` + `GetArea(ST_MIX_MM)`; `ksEdgeDefinition.IsLineSeg/IsCircle/IsArc/…` + `GetLength()`. Sketch axis = line with system style **3** (осевая).
- **МЦХ / mass props**: use **API5 `ksPart.CalcMassInertiaProperties(ST_MIX_MM|ST_MIX_KG)`**`ksMassInertiaParam` (`v`/`m`/`F`/`xc`/`yc`/`zc`), computed on demand. **Do NOT** use API7 `IMassInertiaParam7.Calculate()` — its `Actual` flag sticks after the first calc and won't refresh on API5 geometry changes (stale volume). Bounding box: `ksPart.GetGabarit(full:false, …)`.
- **Snapshot**: `ksDocument3D.SaveAsToRasterFormat(file, ksRasterFormatParam)` renders to a temp file (the in-memory `resultArrayBytes` stays empty when a filename is given); read bytes back. Return to MCP via `ImageContentBlock.FromBytes(bytes, mime)`**not** `Data = bytes` (Data holds base64 bytes). Use `model_snapshot` only for visually-spatial questions; **prefer `describe_model` first** (structural «passport» — bbox + МЦХ + bodies + topology + feature tree + variables, no image token cost).
- Logs go to **stderr** (stdout is the MCP channel). Integration tests reuse one КОМПАС via `KompasFixture`; artifacts land in gitignored `.scratch/`. All integration test classes inherit `IntegrationTestBase` (`IAsyncLifetime`) → `DocumentService.CloseAllAsync` after each test (prevents tab accumulation).
- **Feature tree API**: read via API5 `ksPart.GetFeature()``(ksFeature).SubFeatureCollection(true,false)``ksFeatureCollection`. Cast `(ksFeature)part` does NOT work (RCW QI returns null) — must use `GetFeature()`. `ksFeature.type` returns only coarse `o3d_entity` and does NOT distinguish operations; precise type and params come from `ksFeature.GetObject()``ksEntity.GetDefinition()` + the matching definition (`ksBossExtrusionDefinition.GetSideParam`, `ksFilletDefinition.radius`, `ksChamferDefinition.GetChamferParam`, …). Node names ("Элемент выдавливания:1") are localized by КОМПАС. Features registered by id in `_features` support patterns/mirror; helpers `RequireSketch`/`RequireFeature`.
- **Variables (API5)**: create/read via `ksPart.GetFeature().VariableCollection` (property on root ksFeature) — **NOT** `ksPart.VariableCollection()` (returns external-only). Expression is the leading field; value is derived. Apply via `ksPart.RebuildModel()` (check return); after rebuild the RCW variable is stale → re-read via a fresh collection (`ReadValueFresh`). Dependents recalculate automatically; deletion fails if dependents exist (`RemoveVariable` returns FALSE). **Geometry limitation:** variables drive geometry only in a parametric model (sketch dims linked to variable names); our sketches use literal coords → `set_variable` stores/computes the value but does NOT move geometry. Parametric sketches **unavailable via COM** (`ksCDimWithVariable` not constructible from external automation — see `docs/superpowers/specs/2026-05-27-parametric-sketch-findings.md`; do not retry without new info).
- **Topology / measure API**: `ksFaceDefinition.EdgeCollection` / `GetCylinderParam` / `GetSurface().GetNormal` + `normalOrientation`; `ksEdgeDefinition.GetAdjacentFace(bool)` / `GetVertex(bool)``ksVertexDefinition.GetPoint`; bodies via `ksPart.BodyCollection()``ksBody` (`IsSolid` / `FaceCollection`); measurements via `ksPart.GetMeasurer()``ksMeasurer` (`SetObject1/2`, `unit=ST_MIX_MM`, `Calc`, `distance`/`MinDistance`/`angle` in degrees / `IsAngleValid`).
- **"STEP import without history"** detected structurally: `bodyCount > 0 && no formative features && features.Count <= bodyCount+1` (only origin + body). STEP with edits ("Смещённая плоскость" / "Разрезать" / "Переместить грани" / "Булева операция") already has history.
- **STEP import/export COM pattern** (verified): `IApplication.get_Converter((object)(int)ksConverterFromSTEP=-3)` (pass format code, not DLL path) → `IConverter.ConverterParameters(cmd)``IAdditionConvertParameters.Format=ksConverterFromSTEP``IKompasDocument3D1.ConvertFromAdditionFormat(path, prm)`. Export: `ConvertToAdditionFormat` (format codes AP203/AP214/AP242). Param co-classes (AdditionConvertParameters etc.) are NOT CoCreatable — only via converter factories. Assembly traversal (`list_components`): `IKompasDocument3D.TopPart → IPart7.Parts (IParts7)`. Extract component: `prm.NeedCreateComponentsFiles=true` (writes .m3d files next to STEP) + `IPart7.OpenSourceDocument`.
- **Direct B-rep face editing (`move_face`)** (verified): API7 face via `IPart7.FindObjectsByPoint(x,y,z,true)` → cast to `KompasAPI7.IFace`; containers via runtime COM-QI `(ISurfaceContainer)part`, `(IModelContainer)part`; `FaceMover` (SetFaces + Offset + Direction + Update) moves the face. distance>0 = outward (add material), <0 = inward. Works on parametric and imported B-rep. Full split-reposition workflow (SplitSolids/BodyRepositions) is driveable; only partly exposed as tools (`split_solid_by_plane`, `move_body`, `boolean_union`).
- **Forming ops (API5 definitions)**:
- `shell` (`ksShellDefinition`, o3d_shellOperation=43): `thickness` + `thinType` (bool, `= !outward`) + `FaceArray()` (open faces ≥1 by index). Transient RCWs intentionally not released (consistent with Fillet/Chamfer).
- `rib` (`ksRibDefinition`, =44): `SetSketch(sketch)``index=0`, `angle`, `side` (`left|right|up|down` → 0/1/2/3 via `SketchGeometry.RibSide()`); `SetThinParam(dtBoth, t/2, t/2)` if symmetric, else `(dtNormal, t, 0)`. Empirical: the rib contour must float in the gap (not touch the body) — КОМПАС extends the web automatically.
- `sweep` (`ksBaseEvolutionDefinition`, =45): `SetSketch(profile)``PathPartArray().Add(path)``sketchShiftType=0``SetThinParam(false)``Create()`. Profile & path on different (usually ⟂) planes; `profileSketchId != pathSketchId`.
- `loft` (`ksBaseLoftDefinition`, =30): `Sketchs().Add(each)``SetLoftParam(false,false,true)``SetThinParam(false)`. ≥2 closed sections on parallel planes — use `sketch_create_on_offset_plane` for non-base sections.
- offset plane (`sketch_create_on_offset_plane`, `ksPlaneOffsetDefinition`, o3d_planeOffset=14): `SetPlane(base)` + `offset` mm + `direction``Create()` → sketch on it (`OpenSketchOnOffsetPlaneAsync`).
- `draft` (`ksInclineDefinition`, o3d_incline=42 — API5 calls the op **Incline**, NOT Draft): `FaceArray()` (by index) + `SetPlane(neutral base plane)` + `angle` (degrees) + `direction`. **Empirical, opposite to SDK docs:** `direction=false`=expanding/outward (adds material), `true`=tapering/inward → code maps `def.direction = !outward`. Validate `0<angle<90`. (`o3d_DraftFromEdges=644` / `IDraftFromEdges` is a different op — not used.)
- **Patterns & mirror (API5)**: `linear_pattern` (`ksMeshCopyDefinition`, =35): `SetAxis1(axis)` + `SetCopyParamAlongAxis(true,0,count,step,false)` + `count2=1` (disables 2nd direction → 1D); features via `OperationArray()`. `circular_pattern` (`ksCircularCopyDefinition`, =36): `SetAxis(axis)`; `count1=1` (radial off), `count2=count` (ring), `step2=angle°` between neighbours, `factor2=false`, `inverce=reverse`; `GetOperationArray().Add(feature)`. `mirror_operation` (`ksMirrorCopyDefinition`, =48): `SetPlane(base)` + `GetOperationArray().Add(feature)`. `mirror_body` (`ksMirrorCopyAllDefinition`, =49): `SetPlane(base)`; `ChooseBodies()` casts to null in interop and is NOT needed — mirrors all bodies keeping the original (volume doubles). Empirical: `count` includes the original instance (count=3 → 3 instances). Axes: `CoordinateAxis{X,Y,Z}``o3d_axisOX/OY/OZ` (`Core/Modeling/CoordinateAxis.cs`, analogous to `BasePlane.cs`).
- **Hole ops (API7 only — `ksHoleDefinition` absent in API5 interop; `HoleService`)**: `HoleCore(ksHoleTypeEnum holeType, Action<IHole3D> configure)``configure` sets `HoleParameters` **after** `HoleType` is assigned (the type-specific sub-interface is inaccessible until the type is fixed). Placement shared: `IModelContainer`/`IHole3D`/`IHoleDisposal` + `Points3D`, direction chosen by volume delta, orphan rollback. None registered in `_features` → no id returned. `hole`=`ksHTBase`, configure=null. `hole_counterbore`=`ksHTCounterbore` + `(ISpotfacingHoleParameters)` (`SpotfacingDiameter`/`SpotfacingDepth`). `hole_countersink`=`ksHTCountersinking` + `(ICountersinkHoleParameters)` (`CountersinkType=ksCTDiameterAngle`, `CountersinkDiameter`/`Angle`). `hole_conic`=`ksHTConic` + `(IConicHoleParameters)` (`ConicType=ksCNAngle`, `ConicAngle`).
- **Assembly (API7, `AssemblyService`, namespace `Kompas.Mcp.Core.Assemblies`)**: `assembly_add_component``IParts7.AddFromFile(path, ExternalFile=true, Redraw=true)``IPart7.Placement` (`IPlacement3D`) → `SetOrigin(x,y,z)` (mm, assembly world CS) → `UpdatePlacement(true)``top.RebuildModel(true)`. **Empirical:** `UpdatePlacement` returns FALSE for a manually-positioned component (no mates) — NOT an error. Validate `DocumentType==ksDocumentAssembly` before touching `TopPart`; rollback via `IFeature7.Delete` + `RebuildModel`. `assembly_add_mate``top.MateConstraints` (`IMateConstraints3D`) → `Add(MateConstraintType)``BaseObject1/2` (faces via `top.FindObjectsByPoint(x,y,z,FirstLevel=false)`) → `ParamValue` (distance) → `Update()``RebuildModel(true)`; check `mate.Valid` (false → rollback `mate.Owner.Delete`). Types `coincidence`(mc_Coincidence=0) / `distance`(mc_Distance=5, value>0) verified live; `parallel`/`perpendicular`/`concentric`/`angle`/`tangency` — future.
- **Drawing (API7, `DrawingService`, namespace `Kompas.Mcp.Core.Drawings`)** — entry via `IKompasDocument2D`; requires an active drawing doc (`DocumentType==ksDocumentDrawing`). All coords are **view-local** (mm) unless noted. Model file must be saved before placing views.
- `drawing_create_standard_views`: `doc2d.ViewsAndLayersManager.Views.AddStandartViews(path, "#Спереди", projTypes, x, y, scale, dx=20, dy=20)` where `projTypes=object[]{1,3,5}` (Front/Up/Left as SAFEARRAY VT_I4). Success: `created==3`; content check: sum of new views' `IView.ObjectCount` > 0 else rollback `IView.Delete`. Returns `ViewNumbers` (the `IView.Number` values — address views by these when placing annotations).
- `drawing_fill_title_block`: `doc2d.LayoutSheets.ItemByNumber[1].Stamp` (`IStamp`) → `stamp.Text[columnId].Str = text` (indexed property; `IText.Str` overwrites, no Clear needed) → `stamp.Update()`. Fields→columns (`ksStampEnum`): Name=1, Designation=2, Material=3. Non-transactional (all cells → single `Update`).
- **Dimensions go into `(ISymbols2DContainer)view`, NOT the sheet, and are NOT counted in `IView.ObjectCount`** (that counts geometry in `IDrawingContainer`) — verify placement via the container's `*.Count`. Common flow: `Add()` → set coords → `AutoNominalValue=true``Update()` → check `Valid` → read `((IDimensionText)dim).NominalValue`; rollback `dim.Delete()` on FALSE/invalid/zero. Leader/angle in **radians** (`DimensionAngles.ToRadians`). Free placement = set coords directly; **associative** (diametral/radial only) = set `BaseObject` to a projected circle → value read from geometry (see below).
- linear (`ILineDimension`): `X1,Y1,X2,Y2` (extension pts) + `X3,Y3` (dim line) + `Orientation` (`DimensionOrientation{Horizontal,Vertical,Parallel}``ksLinDParallel=0`/`Horizontal=1`/`Vertical=2`; parallel needs no explicit Angle).
- diametral (`IDiametralDimension`): `Xc,Yc,Radius` + `Angle`; `NominalValue` = 2·Radius.
- radial (`IRadialDimension`): `Xc,Yc,Radius` + `Angle`, `DimensionType=true`; **`NominalValue` = Radius, NOT diameter** — contrary to SDK docs, confirmed by spike.
- **associative** diametral/radial (`associate=true` flag): instead of coords, find a projected circle in `(IDrawingContainer)view.Circles` by center+radius key (`CircularObjectMatch.SelectMatchIndex`, tol 1 mm; throws on none/ambiguous; concentric disambiguated by radius), set `dim.BaseObject = circle` (`_Circle` implements `IDrawingObject`) → `NominalValue` read from geometry. `RequireViewContainers` gives both QIs; free path stays on `RequireSymbols2DContainer`. Arc/angular/rough binding — not yet (own spike needed; `ILineDimension` has no `BaseObject`).
- angular (`IAngleDimension`, `AngleDimensions.Add(ksDrADimension=10)`): vertex `Xc,Yc` + side pts `X1,Y1`/`X2,Y2` + arc-position `X3,Y3` (positions the arc only, does NOT pick the angle); measured angle chosen by `DimensionType` = `AngleDimensionType{Min,Max,More}` (on rays 0°/45° → min=45°/max=135° supplement/more=315° reflex). `NominalValue` in degrees.
- `drawing_add_rough` (`Roughs.Add()`/`IRough`: `BranchX0/Y0`, `Angle`) + `(IRoughParams)rough` QI: `SignType` = `RoughSignType{NoProcessing,DeleteMaterial,WithoutDeleteMaterial}`, value (Ra/Rz) = `RoughParamText.Str`.
- `drawing_add_text`**text lives in `IDrawingContainer`, NOT `ISymbols2DContainer`, and NOT in `IView.ObjectCount`**: `(IDrawingContainer)view.DrawingTexts.Add()`/`IDrawingText` (`X/Y/Angle`) + `(IText)dt.Str` = text; count via `DrawingTexts.Count`. Do NOT set `Height` (that's block height, not font).
- `drawing_set_technical_requirements`**document-level**: `(IDrawingDocument)doc.TechnicalDemand`/`ITechnicalDemand`: `td.Text.Str = text``td.Update()` (`IsCreated` False→True on first set; overwrites; `\n` multiline).
- `DrawingAnnotationResult{Value(read-back), ViewNumber}` is the shared result for rough/text.
- `drawing_add_leader``ISymbols2DContainer.Leaders.Add(ksDrLeader)``IBaseLeader`. **КРИТИЧЕСКИЙ ПОРЯДОК (спайк):** новая выноска имеет 0 ответвлений → `(IBranchs)bl.AddBranchByPoint(0,x,y)` (остриё) ДО `SetBranchTextPosition(textX,textY)`/`Update`, иначе КОМПАС падает `RPC_E_SERVERFAULT`. Текст — `(ILeader)bl.TextOnShelf.Str`; `ShelfDirection` (enum `ShelfDirection{Auto,Right,Left,Up,Down}`, Auto = не задавать). `AddBranchByPoint`/`SetBranchTextPosition` возвращают bool — проверять. Результат — общий `DrawingAnnotationResult`.
- `drawing_set_sheet_format``RequireLayoutSheet(n).Format` (`ISheetFormat`): `Format`=`ksDocumentFormatEnum` (`PaperFormat{A0..A5,User}`+`PaperFormats.Parse/ToKompas/FromKompas`), standard → set `VerticalOrientation=!landscape` (W/H auto-computed from enum+orientation, e.g. A3 landscape→420×297); **User → set `FormatWidth/Height` only, КОМПАС auto-derives `VerticalOrientation` from W/H and ignores the flag, never swaps W/H (spike)**`sheet.Update()`. New drawing defaults A4 portrait. `ValidateFormatDimensions`: User needs W/H>0, standard forbids W/H (must be 0).
- **Richer sketch primitives (API5 `ksDocument2D` wrappers)**: `ksArcBy3Points`; `ksArcByAngle` (centre/radius/start-end angles in degrees + counterClockwise flag); `ksEllipse` via `ksEllipseParam` (`KompasObject.GetParamStruct(ko_EllipseParam=22)`; **property names are `A`/`B` UPPERCASE in the interop**); `ksRegularPolygon` via `ksRegularPolygonParam` (`ko_RegularPolygonParam=92`; `describe = !inscribed`); NURBS spline via `ksNurbs(order=4)` + `ksNurbsPoint` loop + `ksEndObj`; `ksPoint`. Param structs released after use. `PartModeler` is partial: `.cs` (registry/helpers/`NewParam<T>`/reset), `.Sketch.cs` (2D primitives), `.Features.cs` (extrude/revolve/fillet/chamfer). Static `SketchGeometry` (`Core/Modeling`) holds enum maps + validators (`RequirePositive`, `RequireVertexCount`, `RequirePoints`, `RequireMin`). Point lists use `record SketchPoint(X, Y)` (JSON names `x`/`y`).
## КОМПАС-3D API architecture (the critical context)
КОМПАС-3D is automated via a **COM Automation API**, Windows-only. КОМПАС-3D **must be installed and running/launchable** on the same machine — the MCP server is a COM client, not a standalone CAD engine. There are **two coexisting API generations**:
| | Entry point | Namespace (C#) | Style | Use when |
|---|---|---|---|---|
| **API5** (legacy) | `KompasObject` | `Kompas6API5` | procedural, flat | older features, 2D primitives, bootstrapping |
| **API7** (modern, preferred) | `IApplication` / `_Application` | `KompasAPI7` | OOP, interface-based | everything new — documents, 3D, parameters |
Constants live in separate libraries: `Kompas6Constants`, `Kompas6Constants3D` (C#), or `ksConstants.h` / `ksConstants3D.h` (C++).
### Connection pattern (verified from `Samples/CSharp.zip`)
1. Get the КОМПАС root object by COM ProgID. **Verified on this machine (v24 Home):**
`KOMPAS.Application.7` and `KOMPAS.Application.5` are registered; `KOMPASLT.*` is **absent**
the Home edition uses the regular (non-LT) ProgIDs. Resolution order:
- `KOMPAS.Application.7` — direct API7 `IApplication` (preferred)
- `KOMPAS.Application.5` — API5 `KompasObject`, then `ksGetApplication7()`
- `KOMPASLT.Application.5` — fallback for other installations
- New instance: `Activator.CreateInstance(Type.GetTypeFromProgID(progId))`
- Attach to a running instance: `Marshal.GetActiveObject(progId)`
2. If you entered via API5, obtain the modern application: `IApplication appl = (IApplication)kompas.ksGetApplication7();` (via `KOMPAS.Application.7` you already hold `IApplication`).
3. Set `appl.Visible = true` to show the КОМПАС window; drive documents via API7 interfaces (`IKompasDocument2D`, `IKompasDocument3D`, …).
> The SDK C# samples are mostly **КОМПАС plugin libraries** (DLLs loaded *into* КОМПАС as ActiveX, entry methods like `ExternalRunCommand`/`ExternalMenuItem`, registered via `[ComRegisterFunction]`). For an MCP server we want the **opposite direction**: a standalone *external automation client* that connects out-of-process via the ProgID pattern above. Read the samples for API usage, not for the plugin packaging/registration boilerplate.
## SDK layout (read-only reference, not part of this repo)
Under `C:\Program Files\ASCON\KOMPAS-3D v24 Home\SDK`:
- `lib\*.tlb` — COM type libraries to reference / generate interop from: `kAPI5.tlb`, `kAPI7.tlb`, `ksConstants.tlb`, `ksConstants3D.tlb`, `kAPI2D5COM.tlb`, `kAPI3D5COM.tlb`.
- `lib64\` — 64-bit import libs (`kAPI7.lib`, `kAPI5.lib`, …) and `.a` for C++/Builder.
- `Include\` — C++/Pascal headers (`Ks_TLB.h`, `kAPI2D5COM.h`, `LDefin2D.pas`, …).
- `KsAPI\` — the **KsAPI** (Qt/C++ cross-platform flavour): `Include\KsAPI.h`, `ksConstants*.h`, `Lib64\ksAPI.lib`, plus `Help\С чего начать.pdf` and a Linux/Debian 12 build guide.
- `Samples\` — zipped examples per language: `CSharp.zip`, `C++.zip`, `Pascal.zip`, `Basic.zip`. Inside `CSharp.zip`, the `Step1``Step12` and `Step2_API7_2D` / `Step2_API7_3D` projects are the progressive tutorial; `Gayka`, `SlideWrk`, `EventsAuto` are larger feature demos.
- `Help\KOMPAS_SDK_ru-RU.zip` and `KsAPI\Help\KsAPI_Help.zip` — offline copy of the docs.
To inspect a sample without unpacking the whole archive: `unzip -p "<path>\Samples\CSharp.zip" "Automation/Step2_API7_3D/Step2_API7_3D.cs"`. Note the `.cs` sample sources are **Windows-1251 encoded** (Cyrillic comments appear garbled in UTF-8 tools).
## Conventions / gotchas
- **Windows-only & 64-bit**: target x64 to match the installed КОМПАС; mixing bitness breaks COM activation. КОМПАС v24 Home is the installed edition — some full-edition API features may be unavailable.
- **Encoding**: SDK source samples are CP1251. New project files should be UTF-8.
- **COM lifetime**: release COM objects (`Marshal.ReleaseComObject`) / scope them; a leaked reference keeps the КОМПАС process alive.
- **API choice**: prefer API7 for new tool implementations; drop to API5 only for things API7 doesn't expose.
@@ -0,0 +1,115 @@
using System.Runtime.Versioning;
using Microsoft.Win32;
namespace Kompas.Mcp.Core.Interop;
/// <summary>
/// Поиск каталога установленного КОМПАС-3D по реестру COM.
/// <para>
/// Опорная точка — регистрация COM-сервера: <c>HKCR\&lt;ProgID&gt;\CLSID</c> →
/// <c>HKCR\CLSID\{...}\LocalServer32</c>, значение которого содержит путь до исполняемого файла
/// (проверено на v24 Home: <c>"C:\Program Files\ASCON\KOMPAS-3D v24 Home\Bin\kHome.Exe"</c>).
/// Ветка <c>HKLM\SOFTWARE\ASCON\KOMPAS-3D</c> версионная и хранит настройки, а не путь установки,
/// поэтому не используется: <c>LocalServer32</c> заодно гарантирует, что найденная установка —
/// именно та, к которой сервер потом подключится по тому же ProgID.
/// </para>
/// </summary>
public static class KompasInstallLocator
{
// Тот же порядок и тот же набор, что в KompasSession: .7 годится для поиска установки
// (нужен только путь), хотя точкой входа при подключении служит API5.
private static readonly string[] ProgIds =
{
"KOMPAS.Application.7",
"KOMPAS.Application.5",
"KOMPASLT.Application.5",
};
/// <summary>Каталог установки КОМПАС или <c>null</c>, если COM-класс не зарегистрирован.</summary>
[SupportedOSPlatform("windows")]
public static string? TryFindInstallDirectory()
{
foreach (var progId in ProgIds)
{
var root = InstallRootFromServerCommand(TryReadLocalServer(progId));
if (root is not null && Directory.Exists(root))
return root;
}
return null;
}
[SupportedOSPlatform("windows")]
private static string? TryReadLocalServer(string progId)
{
using var progIdKey = Registry.ClassesRoot.OpenSubKey($@"{progId}\CLSID");
if (progIdKey?.GetValue(null) is not string clsid || clsid.Length == 0)
return null;
using var serverKey = Registry.ClassesRoot.OpenSubKey($@"CLSID\{clsid}\LocalServer32");
return serverKey?.GetValue(null) as string;
}
/// <summary>
/// Выделить путь до exe из значения <c>LocalServer32</c>: путь может быть в кавычках и
/// сопровождаться аргументами командной строки (<c>...\kHome.Exe /automation</c>).
/// </summary>
public static string? ParseServerCommand(string? rawServerCommand)
{
if (string.IsNullOrWhiteSpace(rawServerCommand))
return null;
var value = rawServerCommand.Trim();
if (value[0] == '"')
{
var closing = value.IndexOf('"', 1);
return closing > 1 ? value[1..closing] : null;
}
// Без кавычек аргументы отделить надёжно нельзя (пробелы бывают и в пути) — режем
// по расширению исполняемого файла, а не по первому пробелу.
var extension = value.IndexOf(".exe", StringComparison.OrdinalIgnoreCase);
return extension >= 0 ? value[..(extension + 4)] : value;
}
/// <summary>
/// Каталог установки по значению <c>LocalServer32</c>: <c>&lt;root&gt;\Bin\kHome.Exe</c> →
/// <c>&lt;root&gt;</c>. Если exe лежит не в <c>Bin</c>, возвращается его собственный каталог.
/// </summary>
/// <remarks>
/// Разбор пути ручной, а не через <see cref="Path.GetDirectoryName(string)"/>: значение из
/// реестра всегда Windows-пути, а unit-тесты гоняются в том числе на Linux-раннере, где
/// <c>\</c> для <see cref="Path"/> — обычный символ.
/// </remarks>
public static string? InstallRootFromServerCommand(string? rawServerCommand)
{
var executable = ParseServerCommand(rawServerCommand);
if (executable is null)
return null;
var binDirectory = TrimLastSegment(executable);
if (binDirectory is null)
return null;
if (!string.Equals(LastSegment(binDirectory), "Bin", StringComparison.OrdinalIgnoreCase))
return binDirectory;
return TrimLastSegment(binDirectory) ?? binDirectory;
}
private static readonly char[] Separators = { '\\', '/' };
private static string? TrimLastSegment(string path)
{
var trimmed = path.TrimEnd(Separators);
var separator = trimmed.LastIndexOfAny(Separators);
return separator > 0 ? trimmed[..separator] : null;
}
private static string LastSegment(string path)
{
var trimmed = path.TrimEnd(Separators);
var separator = trimmed.LastIndexOfAny(Separators);
return separator >= 0 ? trimmed[(separator + 1)..] : trimmed;
}
}
@@ -0,0 +1,16 @@
namespace Kompas.Mcp.Core.Interop;
/// <summary>
/// Интероп-сборки КОМПАС недоступны: сервер их не поставляет и берёт из установленного КОМПАС-3D.
/// </summary>
/// <remarks>
/// Бросается лениво — из обработчика разрешения сборок, то есть в момент первого обращения к
/// COM-типам (обычно это <c>kompas_connect</c>), а не при старте процесса. Сообщение пишется
/// так, чтобы его можно было показать пользователю без доступа к исходникам.
/// </remarks>
public sealed class KompasInteropException : Exception
{
public KompasInteropException(string message) : base(message)
{
}
}
@@ -0,0 +1,54 @@
using System.Reflection;
using System.Runtime.Loader;
namespace Kompas.Mcp.Core.Interop;
/// <summary>
/// Подключает интероп-сборки КОМПАС из установленного экземпляра CAD, а не из поставки сервера.
/// <para>
/// Вызывается первой строкой <c>Program.Main</c>: обработчик
/// <see cref="AssemblyLoadContext.Resolving"/> должен быть на месте до того, как JIT впервые
/// коснётся COM-типов (это происходит лениво — обычно на <c>kompas_connect</c>). Сам поиск
/// ленивый: регистрация обработчика ничего не ищет и не распаковывает.
/// </para>
/// </summary>
public static class KompasInteropLoader
{
private static readonly object Gate = new();
private static KompasInteropProvider? _provider;
/// <summary>Зарегистрировать резолвер. Повторные вызовы игнорируются.</summary>
public static void Install(KompasInteropProvider? provider = null)
{
lock (Gate)
{
if (_provider is not null)
return;
_provider = provider ?? KompasInteropProvider.CreateDefault();
AssemblyLoadContext.Default.Resolving += Resolve;
}
}
/// <summary>
/// Каталог, из которого будут загружены сборки. Нужен для ранней диагностики при старте:
/// иначе первый же вызов инструмента платил бы за распаковку архива SDK.
/// </summary>
/// <exception cref="KompasInteropException">Сборки не найдены.</exception>
public static string ResolveDirectory()
{
var provider = _provider
?? throw new InvalidOperationException($"Сначала вызовите {nameof(KompasInteropLoader)}.{nameof(Install)}().");
return provider.ResolveDirectory();
}
private static Assembly? Resolve(AssemblyLoadContext context, AssemblyName name)
{
if (!KompasInteropProvider.IsKompasAssembly(name.Name))
return null;
var path = Path.Combine(ResolveDirectory(), name.Name + ".dll");
return File.Exists(path) ? context.LoadFromAssemblyPath(path) : null;
}
}
@@ -0,0 +1,227 @@
using System.Globalization;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
namespace Kompas.Mcp.Core.Interop;
/// <summary>
/// Поиск каталога с интероп-сборками КОМПАС, которые сервер не поставляет вместе с собой.
/// <para>
/// Сборки (<c>KompasAPI7</c>, <c>Kompas6API5</c>, <c>Kompas6Constants</c>, <c>Kompas6Constants3D</c>)
/// принадлежат АСКОН и распространяются в составе КОМПАС-3D, поэтому в релизный архив не входят —
/// они берутся из установленного экземпляра. В установке отдельных DLL нет: они лежат внутри
/// <c>SDK\Samples\CSharp.zip</c> в каталоге <c>Common/</c>, поэтому при первом обращении архив
/// распаковывается в кеш <c>%LOCALAPPDATA%\kompas-mcp\interop\&lt;ключ&gt;</c>.
/// </para>
/// </summary>
public sealed class KompasInteropProvider
{
/// <summary>Переменная окружения с готовым каталогом сборок (обход поиска установки).</summary>
public const string OverrideVariable = "KOMPAS_INTEROP_DIR";
private const string ZipEntryFolder = "Common/";
/// <summary>Имена сборок, которые сервер отдаёт из установки КОМПАС.</summary>
/// <remarks>
/// Компилируется код только против первых четырёх; остальные перечислены потому, что могут
/// понадобиться как зависимости (<c>Kompas6API3D5COM</c> ссылается на <c>Kompas6API2D5COM</c>).
/// </remarks>
public static readonly IReadOnlyList<string> AssemblyNames = new[]
{
"KompasAPI7",
"Kompas6API5",
"Kompas6Constants",
"Kompas6Constants3D",
"Kompas6API2D5COM",
"Kompas6API3D5COM",
"KAPITypes",
};
// Каталог считается пригодным, только если в нём есть все сборки, против которых собран Core.
private static readonly string[] RequiredFiles =
{
"KompasAPI7.dll",
"Kompas6API5.dll",
"Kompas6Constants.dll",
"Kompas6Constants3D.dll",
};
private readonly string? _overrideDirectory;
private readonly string _baseDirectory;
private readonly string _cacheRoot;
private readonly Func<string?> _installDirectory;
private readonly object _gate = new();
private string? _resolved;
public KompasInteropProvider(
string? overrideDirectory,
string baseDirectory,
string cacheRoot,
Func<string?> installDirectory)
{
_overrideDirectory = overrideDirectory;
_baseDirectory = baseDirectory;
_cacheRoot = cacheRoot;
_installDirectory = installDirectory ?? throw new ArgumentNullException(nameof(installDirectory));
}
/// <summary>Провайдер с боевыми источниками: переменная окружения → каталог сервера → реестр.</summary>
public static KompasInteropProvider CreateDefault() => new(
Environment.GetEnvironmentVariable(OverrideVariable),
AppContext.BaseDirectory,
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"kompas-mcp",
"interop"),
TryFindInstallDirectory);
/// <summary>Относится ли сборка с таким простым именем к интеропу КОМПАС.</summary>
public static bool IsKompasAssembly(string? simpleName) =>
simpleName is not null
&& AssemblyNames.Any(name => string.Equals(name, simpleName, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Каталог со сборками. Результат кешируется на время жизни процесса; при неудаче бросает
/// <see cref="KompasInteropException"/> с текстом, пригодным для показа пользователю.
/// </summary>
public string ResolveDirectory()
{
lock (_gate)
return _resolved ??= Locate();
}
private string Locate()
{
if (!string.IsNullOrWhiteSpace(_overrideDirectory))
{
var directory = _overrideDirectory.Trim();
if (HasRequiredFiles(directory))
return directory;
throw new KompasInteropException(
$"В каталоге из переменной {OverrideVariable} ({directory}) нет интероп-сборок КОМПАС " +
$"({string.Join(", ", RequiredFiles)}). Уберите переменную, чтобы сервер взял сборки из " +
"установленного КОМПАС-3D, либо укажите каталог, где эти файлы лежат.");
}
// Сборки, положенные рядом с kompas-mcp.exe вручную, имеют приоритет над установкой:
// это запасной путь для машин, где КОМПАС установлен без компонента SDK.
if (HasRequiredFiles(_baseDirectory))
return _baseDirectory;
var installDirectory = _installDirectory();
if (string.IsNullOrWhiteSpace(installDirectory))
throw new KompasInteropException(Unavailable(
"КОМПАС-3D не найден: COM-класс KOMPAS.Application не зарегистрирован в системе."));
var archive = Path.Combine(installDirectory, "SDK", "Samples", "CSharp.zip");
if (!File.Exists(archive))
throw new KompasInteropException(Unavailable(
$"в установке КОМПАС ({installDirectory}) нет компонента SDK — отсутствует файл {archive}."));
var cacheDirectory = Path.Combine(_cacheRoot, CacheKey(new FileInfo(archive)));
if (HasRequiredFiles(cacheDirectory))
return cacheDirectory;
Extract(archive, cacheDirectory);
if (!HasRequiredFiles(cacheDirectory))
throw new KompasInteropException(Unavailable(
$"в архиве {archive} нет ожидаемых сборок в каталоге {ZipEntryFolder}."));
return cacheDirectory;
}
private static string Unavailable(string reason) =>
"Не удалось найти интероп-сборки КОМПАС (" + string.Join(", ", RequiredFiles) + "). " +
"Они не входят в поставку сервера и берутся из установленного КОМПАС-3D " +
@"(SDK\Samples\CSharp.zip, каталог Common). Причина: " + reason + " " +
"Установите КОМПАС-3D вместе с компонентом SDK или укажите каталог с этими сборками " +
$"в переменной окружения {OverrideVariable}.";
/// <summary>
/// Ключ кеша распаковки. Считается по самому архиву SDK, поэтому обновление КОМПАС (новый
/// размер/время файла) даёт новый каталог кеша, а не подсовывает сборки от старой версии.
/// </summary>
public static string CacheKey(FileInfo archive)
{
var stamp = string.Join(
'|',
archive.FullName.ToLowerInvariant(),
archive.Length.ToString(CultureInfo.InvariantCulture),
archive.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture));
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(stamp)))[..16].ToLowerInvariant();
}
private static bool HasRequiredFiles(string? directory) =>
!string.IsNullOrWhiteSpace(directory)
&& Directory.Exists(directory)
&& RequiredFiles.All(file => File.Exists(Path.Combine(directory, file)));
/// <summary>
/// Распаковать <c>Common/*.dll</c> во временный каталог и атомарно переименовать его в целевой:
/// два стартующих одновременно сервера не должны видеть наполовину распакованный кеш.
/// </summary>
private static void Extract(string archivePath, string targetDirectory)
{
var parent = Path.GetDirectoryName(targetDirectory)
?? throw new KompasInteropException($"Некорректный путь кеша: {targetDirectory}");
Directory.CreateDirectory(parent);
var staging = Path.Combine(parent, ".tmp-" + Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(staging);
try
{
using (var archive = ZipFile.OpenRead(archivePath))
{
foreach (var entry in archive.Entries)
{
if (!entry.FullName.StartsWith(ZipEntryFolder, StringComparison.OrdinalIgnoreCase)) continue;
if (!entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) continue;
entry.ExtractToFile(Path.Combine(staging, entry.Name), overwrite: true);
}
}
try
{
Directory.Move(staging, targetDirectory);
}
catch (IOException) when (HasRequiredFiles(targetDirectory))
{
// Успел параллельный процесс — его результат ничем не хуже нашего.
}
}
finally
{
try
{
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
}
catch
{
// Мусор во временном каталоге не повод валить запуск сервера.
}
}
}
private static string? TryFindInstallDirectory()
{
if (!OperatingSystem.IsWindows())
return null;
try
{
return KompasInstallLocator.TryFindInstallDirectory();
}
catch
{
// Недоступный/нестандартный реестр — трактуем как «установка не найдена».
return null;
}
}
}
+10 -5
View File
@@ -6,23 +6,28 @@
<AssemblyName>Kompas.Mcp.Core</AssemblyName>
</PropertyGroup>
<!-- Вендорские interop-сборки КОМПАС (libs/kompas-interop). -->
<!--
Interop-сборки КОМПАС нужны только для компиляции: Private=false исключает их из вывода
build и publish, поэтому в релизный архив они не попадают (сборки принадлежат АСКОН и
распространяются в составе КОМПАС-3D). В рантайме их подставляет KompasInteropLoader —
из установленного КОМПАС (SDK\Samples\CSharp.zip → Common) или из KOMPAS_INTEROP_DIR.
-->
<ItemGroup>
<Reference Include="KompasAPI7">
<HintPath>$(KompasInteropDir)\KompasAPI7.dll</HintPath>
<Private>true</Private>
<Private>false</Private>
</Reference>
<Reference Include="Kompas6API5">
<HintPath>$(KompasInteropDir)\Kompas6API5.dll</HintPath>
<Private>true</Private>
<Private>false</Private>
</Reference>
<Reference Include="Kompas6Constants">
<HintPath>$(KompasInteropDir)\Kompas6Constants.dll</HintPath>
<Private>true</Private>
<Private>false</Private>
</Reference>
<Reference Include="Kompas6Constants3D">
<HintPath>$(KompasInteropDir)\Kompas6Constants3D.dll</HintPath>
<Private>true</Private>
<Private>false</Private>
</Reference>
</ItemGroup>
+22 -1
View File
@@ -4,6 +4,7 @@ using Kompas.Mcp.Core.Conversion;
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Drawings;
using Kompas.Mcp.Core.Editing;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
using Kompas.Mcp.Core.Startup;
@@ -14,6 +15,11 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
// Interop-сборки КОМПАС в поставку сервера не входят (принадлежат АСКОН) — они берутся из
// установленного КОМПАС-3D. Резолвер вешается до любых обращений к COM-типам; сам поиск ленивый,
// поэтому --version ниже работает и без установленного КОМПАС.
KompasInteropLoader.Install();
// Печать версии — до поднятия хоста: это не MCP-сессия, stdout свободен.
if (CliArguments.IsVersionRequest(args))
{
@@ -51,4 +57,19 @@ builder.Services
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
var app = builder.Build();
// Ранняя диагностика: поиск установки и распаковка сборок здесь, а не внутри первого вызова
// инструмента. Отсутствие сборок на старте не фатально — сообщение уйдёт в stderr, а падение
// случится там, где его увидит агент: на первом обращении к КОМПАС.
var interopLog = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Kompas.Interop");
try
{
interopLog.LogInformation("Interop-сборки КОМПАС: {Directory}", KompasInteropLoader.ResolveDirectory());
}
catch (KompasInteropException ex)
{
interopLog.LogWarning("{Message}", ex.Message);
}
await app.RunAsync();
@@ -21,4 +21,13 @@
<ProjectReference Include="..\..\src\Kompas.Mcp.Core\Kompas.Mcp.Core.csproj" />
</ItemGroup>
<!--
Сервер интероп-сборки не поставляет и берёт их из установленного КОМПАС (см.
Core/Interop/KompasInteropLoader.cs), но тестам они нужны рядом с тестовой сборкой:
unit-тесты гоняются в том числе на Linux-раннере, где КОМПАС нет и подставить их неоткуда.
-->
<ItemGroup>
<None Include="$(KompasInteropDir)\*.dll" CopyToOutputDirectory="PreserveNewest" Visible="false" />
</ItemGroup>
</Project>
@@ -0,0 +1,55 @@
using Kompas.Mcp.Core.Interop;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Разбор значения <c>LocalServer32</c>. Тесты кроссплатформенные намеренно: значения — всегда
/// Windows-пути, но гоняются они и на Linux-раннере, поэтому разбор в локаторе не опирается
/// на <c>Path</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class KompasInstallLocatorTests
{
[Theory]
// Реальное значение на v24 Home — путь в кавычках.
[InlineData("\"C:\\Program Files\\ASCON\\KOMPAS-3D v24 Home\\Bin\\kHome.Exe\"",
"C:\\Program Files\\ASCON\\KOMPAS-3D v24 Home\\Bin\\kHome.Exe")]
[InlineData("C:\\ASCON\\Bin\\kompas.exe", "C:\\ASCON\\Bin\\kompas.exe")]
// Без кавычек аргументы отрезаются по расширению, а не по первому пробелу.
[InlineData("C:\\Program Files\\ASCON\\Bin\\kHome.Exe /automation",
"C:\\Program Files\\ASCON\\Bin\\kHome.Exe")]
public void ParseServerCommand_extracts_executable(string raw, string expected)
=> Assert.Equal(expected, KompasInstallLocator.ParseServerCommand(raw));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ParseServerCommand_returns_null_for_empty(string? raw)
=> Assert.Null(KompasInstallLocator.ParseServerCommand(raw));
[Fact]
public void ParseServerCommand_returns_null_for_unclosed_quote()
=> Assert.Null(KompasInstallLocator.ParseServerCommand("\"C:\\ASCON\\Bin\\kHome.Exe"));
[Fact]
public void InstallRootFromServerCommand_strips_bin_subdirectory()
{
var root = KompasInstallLocator.InstallRootFromServerCommand(
"\"C:\\Program Files\\ASCON\\KOMPAS-3D v24 Home\\Bin\\kHome.Exe\"");
Assert.Equal("C:\\Program Files\\ASCON\\KOMPAS-3D v24 Home", root);
}
[Fact]
public void InstallRootFromServerCommand_keeps_directory_when_not_bin()
{
var root = KompasInstallLocator.InstallRootFromServerCommand("\"C:\\ASCON\\KOMPAS\\kHome.Exe\"");
Assert.Equal("C:\\ASCON\\KOMPAS", root);
}
[Fact]
public void InstallRootFromServerCommand_returns_null_for_empty()
=> Assert.Null(KompasInstallLocator.InstallRootFromServerCommand(null));
}
@@ -0,0 +1,188 @@
using System.IO.Compression;
using Kompas.Mcp.Core.Interop;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Поиск интероп-сборок КОМПАС: приоритет источников, распаковка архива SDK, тексты ошибок.
/// Настоящие DLL не нужны — провайдер проверяет только наличие файлов с нужными именами.
/// </summary>
[Trait("Category", "Unit")]
public sealed class KompasInteropProviderTests : IDisposable
{
private static readonly string[] RequiredFiles =
{
"KompasAPI7.dll",
"Kompas6API5.dll",
"Kompas6Constants.dll",
"Kompas6Constants3D.dll",
};
private readonly string _root = Path.Combine(Path.GetTempPath(), "kompas-interop-tests-" + Guid.NewGuid().ToString("N")[..8]);
public void Dispose()
{
try { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); } catch { }
}
[Theory]
[InlineData("KompasAPI7", true)]
[InlineData("kompas6api5", true)]
[InlineData("Kompas6API3D5COM", true)]
[InlineData("Kompas.Mcp.Core", false)]
[InlineData(null, false)]
public void IsKompasAssembly_matches_only_interop_names(string? name, bool expected)
=> Assert.Equal(expected, KompasInteropProvider.IsKompasAssembly(name));
[Fact]
public void Override_directory_wins_over_everything()
{
var overrideDir = CreateDirectoryWithAssemblies("override");
var baseDir = CreateDirectoryWithAssemblies("base");
var provider = new KompasInteropProvider(overrideDir, baseDir, Cache, () => throw new Xunit.Sdk.XunitException(
"поиск установки не должен вызываться при заданном KOMPAS_INTEROP_DIR"));
Assert.Equal(overrideDir, provider.ResolveDirectory());
}
[Fact]
public void Override_directory_without_assemblies_fails_loudly()
{
var empty = Path.Combine(_root, "empty");
Directory.CreateDirectory(empty);
var provider = new KompasInteropProvider(empty, MissingDirectory, Cache, () => null);
var error = Assert.Throws<KompasInteropException>(() => provider.ResolveDirectory());
Assert.Contains(KompasInteropProvider.OverrideVariable, error.Message, StringComparison.Ordinal);
Assert.Contains(empty, error.Message, StringComparison.Ordinal);
}
[Fact]
public void Base_directory_is_used_when_it_holds_assemblies()
{
var baseDir = CreateDirectoryWithAssemblies("base");
var provider = new KompasInteropProvider(null, baseDir, Cache, () => throw new Xunit.Sdk.XunitException(
"поиск установки не должен вызываться, когда сборки лежат рядом с сервером"));
Assert.Equal(baseDir, provider.ResolveDirectory());
}
[Fact]
public void Missing_installation_reports_actionable_reason()
{
var provider = new KompasInteropProvider(null, MissingDirectory, Cache, () => null);
var error = Assert.Throws<KompasInteropException>(() => provider.ResolveDirectory());
Assert.Contains("KOMPAS.Application", error.Message, StringComparison.Ordinal);
Assert.Contains(KompasInteropProvider.OverrideVariable, error.Message, StringComparison.Ordinal);
}
[Fact]
public void Installation_without_sdk_component_reports_missing_archive()
{
var install = Path.Combine(_root, "KOMPAS-3D v24 Home");
Directory.CreateDirectory(install);
var provider = new KompasInteropProvider(null, MissingDirectory, Cache, () => install);
var error = Assert.Throws<KompasInteropException>(() => provider.ResolveDirectory());
Assert.Contains("SDK", error.Message, StringComparison.Ordinal);
Assert.Contains("CSharp.zip", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Archive_without_expected_entries_reports_empty_common_folder()
{
var install = CreateInstallation("KOMPAS-3D v24 Home", entries: new[] { "Automation/Interop.KGAXLib.dll" });
var provider = new KompasInteropProvider(null, MissingDirectory, Cache, () => install);
var error = Assert.Throws<KompasInteropException>(() => provider.ResolveDirectory());
Assert.Contains("Common/", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Assemblies_are_extracted_from_sdk_archive_into_cache()
{
var install = CreateInstallation("KOMPAS-3D v24 Home", entries: CommonEntries("readme.txt"));
var directory = new KompasInteropProvider(null, MissingDirectory, Cache, () => install).ResolveDirectory();
Assert.StartsWith(Cache, directory, StringComparison.Ordinal);
Assert.All(RequiredFiles, file => Assert.True(File.Exists(Path.Combine(directory, file)), file));
// В кеш попадают только DLL из Common — не весь архив.
Assert.False(File.Exists(Path.Combine(directory, "readme.txt")));
Assert.False(File.Exists(Path.Combine(directory, "Interop.KGAXLib.dll")));
}
[Fact]
public void Extracted_cache_is_reused_by_the_next_process()
{
var install = CreateInstallation("KOMPAS-3D v24 Home", entries: CommonEntries());
var first = new KompasInteropProvider(null, MissingDirectory, Cache, () => install).ResolveDirectory();
var marker = Path.Combine(first, "marker.txt");
File.WriteAllText(marker, "распаковано первым запуском");
var second = new KompasInteropProvider(null, MissingDirectory, Cache, () => install).ResolveDirectory();
Assert.Equal(first, second);
Assert.True(File.Exists(marker), "кеш должен переиспользоваться, а не распаковываться заново");
}
[Fact]
public void Cache_key_follows_the_sdk_archive()
{
var archive = Path.Combine(_root, "CSharp.zip");
Directory.CreateDirectory(_root);
File.WriteAllText(archive, "v24");
var before = KompasInteropProvider.CacheKey(new FileInfo(archive));
// Обновление КОМПАС меняет архив — ключ обязан смениться, иначе подхватится старый кеш.
File.WriteAllText(archive, "v25 — другой размер");
File.SetLastWriteTimeUtc(archive, DateTime.UnixEpoch.AddDays(1));
var after = KompasInteropProvider.CacheKey(new FileInfo(archive));
Assert.NotEqual(before, after);
Assert.Equal(16, before.Length);
}
private string Cache => Path.Combine(_root, "cache");
private string MissingDirectory => Path.Combine(_root, "no-such-directory");
private static string[] CommonEntries(params string[] extra)
=> RequiredFiles.Select(file => "Common/" + file)
.Concat(extra.Select(name => "Common/" + name))
.Append("Automation/Interop.KGAXLib.dll")
.ToArray();
private string CreateDirectoryWithAssemblies(string name)
{
var directory = Path.Combine(_root, name);
Directory.CreateDirectory(directory);
foreach (var file in RequiredFiles)
File.WriteAllText(Path.Combine(directory, file), "заглушка");
return directory;
}
private string CreateInstallation(string name, string[] entries)
{
var install = Path.Combine(_root, name);
var samples = Path.Combine(install, "SDK", "Samples");
Directory.CreateDirectory(samples);
using var archive = ZipFile.Open(Path.Combine(samples, "CSharp.zip"), ZipArchiveMode.Create);
foreach (var entry in entries)
{
using var writer = new StreamWriter(archive.CreateEntry(entry).Open());
writer.Write("заглушка");
}
return install;
}
}