11 Commits

Author SHA1 Message Date
mikhail 69a075c890 Журнал операций: история вызовов инструментов в JSONL
Каждый вызов пишется строкой JSON из фильтра в Program.cs: инструмент,
аргументы, длительность, ok, полный текст ошибки либо начало ответа.
Снимок модели в журнал не идёт — только размер картинки.

Включается только явным путём: KOMPAS_MCP_OPLOG_PATH на старте или
set_operation_log(enabled, path?) в сессии; KOMPAS_MCP_OPLOG=0 глушит,
не стирая путь. Сбой записи операцию не роняет, причина видна в
kompas_status.

tools/dev/oplog-report.ps1 разбирает накопленное: частота и медианное
время по инструментам, доля отказов, топ ошибок, ни разу не вызванные
инструменты — материал для решений, что из каталога убрать и что
переделать.

Заодно поправлены устаревшие счётчики в README: 60 инструментов
(было указано 57 при 59 фактических), 567 тестов вместо 427.
2026-07-31 21:36:28 +03:00
mikhail 6b4563a053 Итерация 2: плоскость уходит с эскизом, list_edges одним вызовом
Протокол второго прогона (48 вызовов, фаска по всему контуру надписи одной операцией):

feature_delete эскиза уносит и смещённую плоскость, созданную ради него, — её id наружу
не выдавался, и в дереве оставалась «Смещенная плоскость:N», которую нечем убрать.

list_edges отдаёт разбивку по типам и готовую строку индексов; при >60 рёбрах печатает
только их. Раньше субагент звал инструмент шесть раз, перебирая type, чтобы убедиться,
что не пропущен тип кривой.

Навык: исправлено МОЁ ЖЕ утверждение из прошлой итерации — карман subtract опасен для
глифов (высота 3 мм), а не для каймы (2 мм, в слой кармана не попадает); запрет на пакетную
обработку рёбер текста сужен до скруглений верхних рёбер — фаска по 59 рёбрам нижнего
контура строится с первой попытки; добавлены формула кегля через одну пробу и кайма
для прямоугольной плашки вторым примитивом.
2026-07-31 21:20:10 +03:00
mikhail e127cebb49 Итерация 1: удаление операции, выравнивание надписи, отбор рёбер
По протоколу регресс-прогона (46 вызовов против 90 в прошлом):

feature_delete — ksDocument3D.DeleteObject + RebuildDocument, зависимые уходят каскадом.
Неудачная проба больше не требует пересобирать документ (у субагента таких кругов было три).

align/vAlign у примитива text: сервер строит, замеряет и сдвигает надпись сам. Ловушка,
стоившая двух заходов: ksMoveObj и ksDeleteObj по ссылке от ksConvertTextToCurve возвращают
успех и не делают ничего — двигать надо по ссылке ТЕКСТА, а габарит после сдвига считать
арифметикой (по текстовой ссылке рамка идёт по ячейке). Поймано тестом, который сверяет
заявленный габарит с выдавленной геометрией.

list_edges: отбор по типу и координатному окну + концы рёбер в списке. Так адресуется
нижний контур под фаску, который иначе не выбрать: индексов не отфильтровать, точек не угадать.

Навык: wf меряет пропорцию крупно (1.0→1.25 = +29 %), но квантован ступенями — точную ширину
набирают height; прирост каймы указан НА СТОРОНУ и зависит от начертания, а не от шрифта;
добавлено предупреждение, что primitive(subtract) режет наехавшие буквы.
2026-07-31 17:11:53 +03:00
mikhail 5ef452ceb7 Навыки и README по итогам регресс-прогона
Снято два неверных утверждения kompas-3d: ширина линейна по widthFactor (замер даёт
+0.55 % коэффициента → +0.07 % ширины) и проекции #Спереди/#Сверху у model_snapshot,
которых у инструмента нет. Добавлено: габарит глифов вместо ячейки, миттер на острых
терминалах (t/sin(θ/2)), трекинг кратными пробелами, замер в черновом документе,
требование называть каждую операцию.

kompas-mcp-dev: правила формы (name у операции, ответ несёт данные для следующего шага),
проверенные COM-цепочки, делегирование субагенту cad-engineer. В бэклог CLAUDE.md —
отсутствие feature_delete/sketch_update и фильтра подмножества рёбер.
2026-07-31 16:36:13 +03:00
mikhail 31b933e1cc Ответ надписи — габарит глифов; запись на диск создаёт каталог
ksConvertTextToCurve ВОЗВРАЩАЕТ ссылку на кривые, и только по ней ksGetObjGabaritRect
даёт габарит букв: по текстовой ссылке это ячейка строки (Arial h=10 «HH» — 23.75 против
19.97 у глифов, второе совпадает с выдавленным телом). Агент вписывает надпись в поле
по габариту, поэтому в ответе теперь оба числа.

OutputPath.Prepare создаёт каталог назначения и делает путь абсолютным (document_save,
export_step, снимок): КОМПАС в несуществующую папку молча не пишет, а диагностика при
этом винила занятое имя файла — чинили не ту причину. Причины разведены.
2026-07-31 16:36:01 +03:00
mikhail 461e731857 Дерево построения: имя у каждой операции, скругление пакетом по точкам
Имя (name) прокидывается до создания объекта: API5 — ksEntity.name перед Create(),
API7-пути (примитив, отверстие, булева, рез, перенос грани) — IModelObject.Name перед
Update(). Читается назад через ksFeature.name, который только на чтение. Без имени
дерево состоит из «Эскиз:1» и «Элемент выдавливания:3».

fillet_edge/chamfer_edge принимают список точек: на теле с сотнями рёбер list_edges
неподъёмен, а выбор по точке брал одно ребро — четыре угла давали четыре операции.
Ненайденные рёбра перечисляются ВСЕ сразу, модель при этом не мутируется.
2026-07-31 16:35:44 +03:00
mikhail 814c9cf7b1 Субагент cad-engineer: прогон CAD-задачи с чистым контекстом
Отладочный механизм: субагент строит модель ТОЛЬКО инструментами MCP и по навыкам,
без подсказок вызывающего, а затем сдаёт протокол — вызовы, затыки, цитаты из навыка,
чего не хватило в каталоге. Пишущих инструментов у него нет намеренно: нехватка
возвращается находкой, а не самодельным обходом. Проектный, в плагин не входит.
2026-07-31 16:35:30 +03:00
mikhail d0c9d8ca3f Навыки и README: два пути формообразования, надписи, проверенные факты
kompas-3d: раздел «Два пути формообразования — выбирай по форме, а не по
привычке» (призматика и карманы примитивами, текст и кривые эскизами) и
раздел про надписи с числами, снятыми на практике: height задаёт высоту
ПРОПИСНОЙ, а не габарит строки; разрыв между advance-длиной и габаритом
глифов зависит от шрифта (1-2 % у наборных, 7-9 % у скриптовых) — прежние
формулировки были выведены из одного замера и оказались неверны.

Там же — почему тонкая стенка по многоконтурному эскизу не строится поверх
пересекаемого тела (разложено экспериментом: число тел, происхождение тела
и chooseType ни при чём) и как скруглять рельефный текст: по одному ребру
строится, пакетом по всем 203 — нет.

CLAUDE.md и kompas-mcp-dev: спайк-проект под кейс допустим для проверки
теории, но итоговая деталь всегда строится инструментами MCP — раннер
обходит тот самый слой, который мы проверяем.
2026-07-31 14:51:29 +03:00
mikhail 4f4dd082d9 Документы и диагностика: имя детали, честное сохранение, причина ошибки
set_part_info задаёт наименование и обозначение детали: без него в дереве
стоит безликая «Деталь», и она же уходит в штамп чертежа и в спецификацию.
describe_model показывает наименование в шапке, а безымянную деталь помечает
явно. Имя файла этого не заменяет — это разные свойства.

document_save рапортовал «Сохранено», ничего не записав: SaveAs возвращает
void и молча отказывает, если файл с этим именем уже открыт в КОМПАС другим
документом. Теперь путь и наличие файла проверяются после записи, а Save
сверяет флаг Changed. Освободить занятое имя нечем не было — document_close
получил режим all.

Текст исключения доходил до клиента только у McpException, всё остальное
подменялось на «An error occurred invoking». То есть подсказки в наших
сообщениях агент не видел вовсе. Фильтр CallTool в Program.cs возвращает
IsError с реальной причиной (ToolErrorText разворачивает AggregateException
и склеивает вложенные причины). Там же — регистрация PrimitiveService.
2026-07-31 14:51:12 +03:00
mikhail a4cc755fb4 Формообразование: надписи в эскизе, тонкая стенка, тела по размерам
Три способа получить форму там, где раньше был только замкнутый контур
из отрезков и дуг.

1. Примитив эскиза type=text: ksTextEx + ksConvertTextToCurve. Пока текст
   остаётся текстом, это оформление, и операция его не видит; после
   конвертации глифы становятся обычным сечением. Ответ возвращает
   фактическую длину строки — иначе ширину шрифта до выдавливания не узнать.

2. extrude(thinThickness, thinSide): контур трактуется как стенка заданной
   толщины. outward по замкнутому контуру даёт кайму-эквидистанту вокруг
   него — так надпись получает подложку без 2D-эквидистанты.
   Толщину при dtReverse КОМПАС читает из reverseThickness: положить её в
   normalThickness значит молча получить СПЛОШНОЕ сечение вместо рамки.

3. Инструмент primitive: элементарные тела API7 (block, cylinder, sphere,
   cone) с result=new|union|subtract|intersect. Карман и паз строятся
   вычитанием тела, без эскиза и выреза. Update() возвращает TRUE даже
   когда вычитание прошло мимо тела, поэтому сервис сверяет объём до и
   после и откатывает операцию, если ничего не изменилось.
2026-07-31 14:50:56 +03:00
mikhail dfe5636290 Dev-скрипт сборки: .Count падал на единственном держателе bin
Get-BlockingProcess возвращает скаляр, когда bin занят ровно одним процессом
MCP, и .Count под Set-StrictMode роняет скрипт вместо диагностики. Та же
ловушка, что описана для лаунчера, — теперь результат заворачивается в @().
2026-07-31 14:50:38 +03:00
68 changed files with 4291 additions and 270 deletions
+85
View File
@@ -0,0 +1,85 @@
---
name: cad-engineer
description: "CAD-инженер: выполняет CAD-задачу ЦЕЛИКОМ (деталь, сборка, чертёж) инструментами MCP-сервера `kompas`, с нуля и с чистым контекстом. Отладочный механизм проекта: основная сессия правит навыки и сервер, затем повторно натравливает этого субагента на ту же задачу и по его протоколу видит, стало ли лучше. Вызывай, когда нужно (а) построить модель по готовому ТЗ без пошагового контроля, (б) прогнать регресс навыка `kompas-3d` / `kompas-fdm-design` после правки, (в) проверить, достаточно ли текущего каталога инструментов, чтобы задача решалась без подсказок. НЕ вызывай для доработки самого сервера (это основная сессия + навык `kompas-mcp-dev`) и для точечных операций в уже открытой модели — их дешевле сделать самому.\n"
tools: "Skill, Read, Glob, Grep, mcp__kompas"
model: inherit
color: green
---
Ты — **CAD-инженер**: получаешь задачу и доводишь её до готового результата в КОМПАС-3D
**только вызовами инструментов MCP-сервера `kompas`**.
Ты работаешь в проекте `kompas3d-mcp` (MCP-сервер, транслирующий COM API КОМПАС в инструменты).
Твой прогон — одновременно **испытание сервера и навыков**: вызывающая сторона правит их по твоему
протоколу и запускает тебя снова на той же задаче. Поэтому от тебя нужны две вещи: **результат** и
**честный след того, как он дался**.
## Железные правила
1. **Строишь только инструментами `mcp__kompas__*`.** Ни dotnet-раннеров, ни скриптов, ни ручных
правок файлов модели — у тебя для этого и инструментов нет. Раннер обходит ровно тот слой,
который проверяется (схемы, разбор входа, форма ответа, авто-валидация).
2. **Ты ничего не чиним и не правишь.** Код сервера, навыки, документация — не твоя зона: нехватка
инструмента или пробел в навыке это **находка**, которую ты описываешь, а не повод обойти.
3. **Начинаешь с чистого листа.** Считай, что методики ты знаешь только из навыков — загрузи
`kompas-3d` (механика построения) и, если задача про печать, `kompas-fdm-design`. Если навык
молчит или противоречит наблюдаемому — так и запиши, не «додумывай за него» молча.
4. **Вопросов задать некому.** Пользователя рядом нет: неоднозначность решай сам, разумным
инженерным выбором, и вынеси решение в раздел «Допущения». Не останавливайся на полпути ради
уточнения.
5. **Не полируй отчёт.** Три неудачные попытки перед удачной ценнее гладкого рассказа об успехе.
Скрытый затык — это баг, который переживёт правку.
## Порядок работы
1. **Прочитай ТЗ.** Картинку чертежа/эскиза открывай через `Read` — ты её видишь. Выпиши размеры,
которые собираешься держать, до первого вызова.
2. **`kompas_connect`**, затем `document_create` (или `document_open`, если работаешь по готовому).
3. **Загрузи навык** `kompas-3d`; для печатной детали ещё `kompas-fdm-design` (там первым шагом
калибровка `w`/`h`/`θ_max` и выбор ориентации — при отсутствии данных бери дефолты и скажи об этом).
4. **Строй.** Зависимые вызовы — строго по одному, дожидаясь ответа: сессия держит эскизы по `id`,
а сервер отвечает конкурентно. Каждая мутирующая операция сама дописывает итог проверки
построения — читай его, а не пропускай.
5. **Проверяй геометрию числом, а не видом.** `describe_model` (габарит, объём, число тел),
`measure`, `list_faces` / `list_edges` / `list_bodies`. Расхождение с ТЗ больше допуска — это
не «сойдёт», это либо правка, либо явно объявленное отступление.
6. **Назови и сохрани.** `set_part_info(name=…)` — деталь без имени в дереве выглядит как «Деталь»;
`document_save(path=…)` в указанную папку; `export_step`, если задача про печать или обмен.
Проверь, что сохранение подтверждено ответом инструмента.
7. **Отчитайся** по форме ниже.
## Тупик — тоже результат
Если операция не строится после **трёх** осмысленных попыток (разные параметры/порядок, не повтор
того же), не грызи её дальше: зафиксируй, что именно отказало и при каких условиях, обойди другим
маршрутом (примитивы вместо эскиза, иной порядок операций, упрощение элемента) и отметь замену как
вынужденную. Если обхода нет — доведи всё остальное и сдай задачу частично выполненной, назвав
недостающее. **Частичный результат с точным диагнозом полезнее, чем красиво выглядящая деталь,
собранная не тем способом, о котором спрашивали.**
## Формат финального ответа
Твой ответ читает не человек, а вызывающая модель — пиши плотно, без вступлений и похвал.
**1. Результат.** Готово / готово частично / не готово. Габарит, объём, число тел, чистота
построения. Полные пути сохранённых файлов.
**2. Протокол вызовов.** Нумерованный список: инструмент + значащие параметры + чем ответил, если
ответ повлиял на следующий шаг. Это должен быть воспроизводимый рецепт — по нему повторяют прогон.
**3. Затыки.** Таблица: что делал → что получил → сколько попыток → чем разрешилось. Сюда же —
бесполезные сообщения об ошибках («не построено» без причины) и операции, которые молча сделали
не то, что обещали.
**4. Претензии к навыку.** Чего не хватило, что ввело в заблуждение, где формулировка допускает два
прочтения. **Цитируй строку навыка**, а не пересказывай. Отдельно — приёмы, которые ты нашёл сам и
которых в навыке нет.
**5. Претензии к MCP.** Инструмента нет вовсе / параметр неудобен или неочевиден / ответ не содержит
того, что нужно для следующего шага / валидация пропустила заведомо неверный вход.
**6. Допущения.** Каждое решение, принятое за отсутствующего пользователя, с обоснованием в одну строку.
Если разделы 3–5 пусты — так и напиши «пусто». Пустые 3–5 при выполненной непростой задаче это
сильный сигнал, что навык и каталог инструментов дозрели; выдумывать замечания ради заполнения
формы не надо.
+57 -1
View File
@@ -30,7 +30,7 @@ description: Внутренняя методика доработки самог
## Правила формы инструмента (каталог оптимизирован под агента)
Каталог сознательно сжат с 84 инструментов до 56 — экономится не только контекст, но и число
Каталог сознательно сжат с 84 инструментов до 57 — экономится не только контекст, но и число
вызовов, а каждый лишний вызов это лишний шанс сбиться. Держи форму:
- **Инструмент = смысл операции, вариант = параметр.** `extrude(mode=boss|cut)`,
@@ -46,16 +46,64 @@ description: Внутренняя методика доработки самог
- **Схема не запрещает неверную комбинацию полей — это делает валидация.** Сообщение называет
`type=…` и недостающий параметр (`SketchEntityInputs`, `FeatureTools.Required`), иначе агент
тратит лишний ход на догадки.
- **Текст исключения доходит до клиента только через фильтр.** MCP-SDK показывает сообщение лишь у
`McpException`, остальное подменяет на «An error occurred invoking '<tool>'» — то есть без
фильтра ВСЕ наши подсказки в исключениях пропадают. В `Program.cs` стоит
`WithRequestFilters(... AddCallToolFilter ...)`, который отдаёт `CallToolResult{IsError=true}` с
текстом от `ToolErrorText.Describe` (Core/Startup: разворачивает `AggregateException`, склеивает
до трёх вложенных причин через «←»). Пишешь новое сообщение об ошибке — помни, что до агента
дойдёт именно оно.
- **У мутирующей операции есть `name` — имя узла в дереве построения.** Прокидывается до создания
объекта (`PartModeler.NewEntity` для API5, `IModelObject.Name` до `Update()` для API7-путей),
нормализуется в `FeatureName`. Заводишь новую операцию — добавь параметр сразу: дерево из
«Эскиз:1…Эскиз:9» нечитаемо ни человеку, ни агенту, вернувшемуся к модели.
- **Ответ операции несёт то, что нужно для СЛЕДУЮЩЕГО шага.** Надпись отдаёт габарит глифов
(`TextMetrics`), а не только ширину ячейки; примитив проверяется по объёму. Иначе агент строит
вслепую и пересобирает документ ради замера — цикл, который стоил регресс-прогону четырёх кругов.
- **Мутирующая операция сама дописывает итог проверки** через `AutoValidation.AnnotateAsync`
(`Core/Validation`). Переключатель — `set_auto_validate` и `KOMPAS_MCP_AUTOVALIDATE`; проверка
никогда не превращает удачную операцию в ошибку (сбой самой проверки уходит в примечание).
## Журнал операций: чем подтверждать решения о каталоге
Решения «этот инструмент убрать, тот переделать» опираются на историю вызовов, а не на память о
прошлых прогонах. Сервер пишет каждый вызов строкой JSON: `ts`, `tool`, `args`, `ms`, `ok`,
`error` (текст целиком) либо `result` (первые 500 символов) + `resultChars`, а также `session` и
`client` — по ним история режется на прогоны.
- **Устройство.** `Core/Diagnostics/OperationLog` (+`OperationLogEntry`, `OperationLogSettings`),
запись — из фильтра вызовов в `Program.cs`: единственное место, через которое проходит каждый
инструмент, там же формируется и текст ошибки. Перевод MCP-контекста в запись — `Host/ToolCallLog`
(Core о протоколе не знает: его тесты гоняются без MCP-хоста). Снимок модели в журнал не
попадает — вместо base64 пишется размер картинки.
- **Включение.** Только явным путём: `KOMPAS_MCP_OPLOG_PATH` на старте (в `.mcp.json`, секция `env`)
или `set_operation_log(enabled, path?)` в сессии; `KOMPAS_MCP_OPLOG=0` глушит, не стирая путь.
Состояние показывает `kompas_status`. Сбой записи не имеет права уронить операцию — он оседает в
`LastError` и виден в статусе.
- **Разбор.** `pwsh -NoProfile -File tools/dev/oplog-report.ps1 [-Path …]` — частота и медианное
время по инструментам, доля отказов, топ текстов ошибок и список ни разу не вызванных инструментов
(каталог для сверки берётся из атрибутов `McpServerTool` в исходниках, а не из README).
- **Как читать.** Инструмент с высокой долей отказов — кандидат на доработку (смотри тексты ошибок:
чаще это непонятная агенту схема, а не COM); инструмент, которого нет в журнале после нескольких
разных задач, — кандидат на слияние с соседом или удаление; долгие вызовы — кандидаты на пакетный
параметр. Прежде чем удалять, проверь, что задача такого рода вообще попадала в прогоны: пустота
бывает от того, что сценарий не запускали, а не от ненужности инструмента.
## Продуктизация приёма
> Если нужного инструмента ещё нет — не хардкодь обход под кейс. Заведи кейс в `usecases/`,
> докажи механику (спайк или минимальный прогон), затем продуктизируй как **общий** инструмент MCP
> (с тестами, TDD) и опиши приём в навыке `kompas-3d`.
**Спайк — только чтобы проверить теорию; результат выдаётся инструментами MCP.** Отдельный
dotnet-проект в `usecases/<кейс>/` (ссылка на `Kompas.Mcp.Core`, `KompasInteropLoader.Install()`
первой строкой) — законный способ измерить метрику, проверить COM-цепочку или опровергнуть
утверждение навыка. Но **итоговая деталь/чертёж всегда строится вызовами MCP-инструментов**:
раннер обходит ровно тот слой, который мы и проверяем (схемы инструментов, разбор входа, форму
ответа, авто-валидацию), поэтому «построено раннером» не считается за проверку сервера. Если
инструментов не хватает — это и есть находка кейса: доработать сервер, а не достроить в обход.
Проверочный проект после кейса удаляй, полученные числа переноси в `plan.md` кейса и в навык.
Порядок для нового инструмента:
1. **Сигнатуры и константы** — через субагента `kompas-sdk-research` (см. ниже), затем перепроверка
@@ -86,6 +134,14 @@ CI гоняет только `Category=Unit&Requires!=Windows` на Linux-ран
## Делегирование
**Прогон задачи целиком** — субагент **`cad-engineer`** (`.claude/agents/`, наследует модель; из
инструментов — только навыки, чтение и MCP `kompas`, ничего пишущего). Отладочный цикл навыка и
каталога: правишь `plugin/skills/*` или сервер → натравливаешь субагента на ту же задачу с нуля →
читаешь его протокол (вызовы, затыки, цитаты из навыка, чего не хватило в инструментах). Смысл именно
в чистом контексте: если задача решается только с твоими подсказками по ходу — значит, недоработан
навык, а не субагент. Правок он не делает принципиально, поэтому нехватка инструмента возвращается
находкой, а не самодельным обходом. В плагин не входит.
**Поиск по справке SDK** (сигнатуры, константы, перечисления, цепочки COM-вызовов) — субагент
**`kompas-sdk-research`** (Haiku, read-only). Справка живёт **не в репозитории**, а в RAG-базе
`kompas-sdk` (MCP-сервер, репозиторий `kompas-sdk-docs`) — грепать по проекту бесполезно, доступ к
@@ -205,3 +205,86 @@
`.Features.cs` (extrude/revolve/fillet/chamfer). Static `SketchGeometry` (`Core/Modeling`) holds the enum maps
+ validators (`RequirePositive`, `RequireVertexCount`, `RequirePoints`, `RequireMin`). Point lists use
`record SketchPoint(X, Y)` (JSON names `x`/`y`).
- **Text in a sketch → curves (`type=text`, verified live)**: a text object is *annotation* — a formative
operation never sees it. The chain that works: build `ksTextParam` (`ko_TextParam=28`) → `Init()` →
`GetParagraphParam()` (`ksParagraphParam`: `x`, `y`, `ang`, `height`, `width`) → `SetParagraphParam`;
one `ksTextItemParam` (`ko_TextItemParam=31`, `type=ksTItString=0`, `s=<string>`) whose `GetItemFont()`
(`ksTextItemFont`) carries `fontName`/`height` (mm)/`ksu` (width factor)/`bitVector`
(`ITALIC_ON=0x40`, `BOLD_ON=0x100` — from `Include/ldefin2d.h`); wrap it in
`KompasObject.GetDynamicArray(TEXT_ITEM_ARR=4)` → `ksTextLineParam` (`ko_TextLineParam=29`,
`SetTextItemArr`) → `GetDynamicArray(TEXT_LINE_ARR=3)` → `SetTextLineArr`; then
`ksDocument2D.ksTextEx(param, align)` → **`ksConvertTextToCurve(ref)`** — this is what turns glyphs into
NURBS/lines that extrude. Traps: the `align` argument of `ksTextEx` does **not** move the string (a
centred value still lays it out right-and-up from the insertion point) — instead read
`ksGetTextLengthFromReference(ref)` (advance width in mm, ≈ 10 % wider than the glyph bbox) and place the
text yourself; the dynamic arrays live in КОМПАС, so `ksDeleteArray()` them in a `finally`; an unknown
font name is silently substituted rather than failing; `height` sizes the cap+ascender, not the bbox.
- **Glyph bounding box (verified live)**: `ksConvertTextToCurve(ref)` **returns a new reference — the
curves**, and only that one gives the real box. `ksDocument2D.ksGetObjGabaritRect(reference, ksRectParam)`
(`ko_RectParam`; `GetpBot()`/`GetpTop()` → `ksMathPointParam.x/y`, **millimetres of the sketch**, not
sheet centimetres) on the *text* reference returns the cell (23.75 mm for Arial h=10 «HH») while the
same call on the *converted* reference returns the glyphs (19.97 mm, matching the extruded solid to
0.05 mm). Returns 0 rather than throwing when the object has no box — treat as «unknown», don't fail
the primitive. Test: `SketchTextTests.Reported_glyph_box_matches_the_extruded_geometry`.
- **Moving a text after it was measured (`align`/`vAlign`, verified live)**: `ksDocument2D.ksMoveObj(ref, dx, dy)`
works **only through the TEXT reference** (the one `ksTextEx` returned). Called with the curves reference
from `ksConvertTextToCurve` it returns success and moves nothing — the sketch geometry stays put, which
the extrusion then proves. `ksDeleteObj` behaves the same way: success, and the curves are still there, so
«insert → measure → delete → re-insert» silently leaves TWO strings in the sketch. After a move, do not
re-read the box through either reference: the text reference reports the *cell*, so shift the measured
glyph box arithmetically instead. Test: `TextAlignmentTests`.
- **Deleting a feature (`feature_delete`, verified live)**: `ksDocument3D.DeleteObject(ksEntity)` → TRUE,
then `RebuildDocument()`. Dependents cascade: deleting a sketch removes the extrusion built on it. A
sketch still in edit mode must be `EndEdit`-ed first. Test: `FeatureDeleteTests`.
- **Edge endpoints for filtering (`list_edges` window, verified live)**: `ksEdgeDefinition.GetVertex(bool)`
→ `ksVertexDefinition.GetPoint(out x, out y, out z)` gives both ends in world mm; some curves have no
vertices (returns null/throws) — treat as «unknown», don't fail the listing. That is what makes
«all edges at z=0» addressable on a body with hundreds of edges.
- **Feature names in the tree (`name` on every mutating tool, verified live)**: API5 `ksEntity.name` is
read/write and must be assigned **before `Create()`** — `PartModeler.NewEntity(part, type, name)` does it
for every entity, sketches included. API7 objects created outside `ksPart` (`IElementaryBody` from
`ElementaryBodies.Add`, `IHole3D` from `Holes3D.Add`) take it via `IModelObject.Name` set before
`Update()`. Reading back is asymmetric: `ksFeature.name` (what `list_features`/`describe_model` walk) is
**read-only** — the tree shows the assigned name, but you cannot rename through `ksFeature`.
Test: `FeatureNamingTests`.
- **Elementary bodies / primitives (`primitive`, verified live)**: `(IPart7 as IModelContainer)
.ElementaryBodies.Add(ksObj3dTypeEnum)` → cast to `IBlockBySizes` / `ICylinder` / `ISphere` /
`IConeByHeight` → set sizes → `Update()`. Types: `o3d_BlockBySizes=668`, `o3d_Cylinder=663`,
`o3d_Sphere=662`, `o3d_ConeByHeight=670` — and `Add` takes **`ksObj3dTypeEnum`, not `Obj3dType`**
(same numbers, different type; the compiler catches it). Placement is the primitive's own
`Position` (a `LocalCoordinateSystem` with writable X/Y/Z); for a block the anchor is a corner
(`BlockPosition = ksVertex`), for bodies of revolution the base centre. `OperationResult`
(`ksOperationUnion=0 / NewBody=1 / Cut=2 / Intersect=3`) makes the primitive a boolean in itself —
a pocket needs no sketch and no cut extrusion. Traps: `Height`, `HeightType`, `HeightOffset` and
cone's `Diameter` are **indexed properties** (`Height[true] = …`, index = the direction side), plain
assignment does not compile; and `Update()` returns TRUE for a subtraction that misses the body
entirely, so `PrimitiveService` compares the volume before/after and rolls the feature back
(`Owner.Delete()`) when nothing changed.
- **Part name/marking (`set_part_info`)**: write through API7 `IPart7.Name` / `IPart7.Marking` on
`IKompasDocument3D.TopPart`, then **`top.Update()`** — without it the tree keeps showing «Деталь».
Read-back through the same properties (API5 `ksPart.name` / `ksPart.marking` also expose them, which is
what `ModelInspectionService` uses, since its `GetTopPart` is already an API5 `ksPart`). The document's
file name is a different thing entirely: saving as `Badge.m3d` does not rename the part.
- **`IKompasDocument.SaveAs(path)` returns `void` and fails silently** when a file of that name is already
open in КОМПАС as another document: nothing is written, no error, the document simply stays unsaved.
`DocumentService.SaveAsAsync` therefore verifies afterwards — `doc.PathName` must equal the requested
path AND `File.Exists(path)` — and `SaveAsync` checks `doc.Changed == false`. Covered by
`DocumentTests.SaveAs_over_a_name_held_by_another_open_document_fails_loudly`. To free the name,
`document_close(all: true)` (→ `DocumentService.CloseAllAsync`) closes every open document.
- **Thin-wall extrusion (`extrude(thinThickness, thinSide)`)**: `ksBoss/CutExtrusionDefinition.SetThinParam(
thin, thinType, normalThickness, reverseThickness)` with `thinType` = `dtNormal` (outward), `dtReverse`
(inward), `dtBoth` (half each side). **Trap: the thickness must sit in the field matching the direction**
— with `dtReverse` the *reverseThickness* argument is the one read; passing it as `normalThickness`
leaves the wall at zero and КОМПАС silently extrudes a SOLID section instead of failing (caught only by
the volume: a 82×10.5 plate came out full instead of a 1.2 mm frame). `outward` over a closed contour is the cheap equivalent of a 2D
equidistant: it grows a rim around it (verified — a 20×20 square with 2 mm outward wall gives a 24×24
frame). Building the same sketch twice (solid + outward wall) yields a silhouette with an offset border,
which is how a lettered badge gets its backing plate without `ksEquidistant`.
**Intersection trap (isolated by experiment, not guessed):** a thin-wall extrusion over a
*multi-contour* sketch (a text line is ~10 closed contours, several with inner loops) returns
`Create()==FALSE` when the wall **intersects an already existing body**. Bisected: empty part → OK;
body present but not touched by the wall → OK; wall crosses the body → FAIL. Body count is
irrelevant (2+ bodies with a plain rectangular contour build fine), so is the body's origin
(sketch or `ElementaryBodies` primitive), and `chooseType` (tried `-1/0/1/2/3`) changes nothing —
the earlier "multi-body" explanation was wrong. Practical rule: build the thin wall before the body
it would cross; `primitive(result=union|subtract)` has no such restriction.
+33 -2
View File
@@ -39,7 +39,7 @@ validated live. Also packaged as the Claude Code plugin `kompas` (no release pub
The full tool catalog (by group) lives in [`README.md`](README.md) §«Инструменты» — the single
source of truth; do not duplicate it here.
**The catalog is shaped for the agent, not for the SDK** (56 tools, down from 84): a tool names the
**The catalog is shaped for the agent, not for the SDK** (60 tools, down from 84): a tool names the
*operation*, the variant is a parameter (`extrude(mode=boss|cut)`, `hole(type=…)`, `pattern(kind=…)`);
an object is picked by index **or** point through one tool (no `*_index` twins); list parameters
(`entities[]`, `edgeIndices[]`) run in a single STA hop; and every mutating op appends its own build-check
@@ -65,6 +65,15 @@ a *general* tool with tests, and describe the method in the `kompas-3d` skill.
arc/angular/rough dimensions (needs its own spike — `ILineDimension` has no `BaseObject`).
- **Assembly**: mate types beyond `coincidence`/`distance``parallel`, `perpendicular`, `concentric`,
`angle`, `tangency` (enum values exist in `MateType.cs`, not verified live).
- **Re-editing an operation**: `feature_delete` now removes a sketch or feature (dependents go with it),
but there is still no `sketch_update` — changing one dimension means deleting the sketch and rebuilding
everything downstream. Re-entering `ksSketchDefinition.BeginEdit` on an existing sketch is unexplored.
- **No minimum-clearance query**: the second regression run caught a 0.695 mm channel between a glyph
and a frame wall (below 2·w for FDM) only by doing arithmetic on the sketch metrics — `describe_model`
knows nothing about it. A general «minimum distance between these faces/bodies» would close it.
- **`model_snapshot` takes no view** — raised by both regression runs: the image comes from whatever
orientation the window happens to have, so a flat part cannot be checked from the front. Look at
the projection API (`ksDocument3D`/`IViewProjectionCollection`) before adding a `view` parameter.
- **Known caveat:** boss/cut direction on a *selected face* depends on the face-normal orientation —
`forward` may need flipping. Pick the direction from the `list_faces(index=…)` normal or a snapshot
before building.
@@ -85,11 +94,26 @@ is a bug report, not a reason to work around it by hand. Three feedback channels
Reading the sources, proposing patches, editing and rebuilding are all explicitly sanctioned — including
asking for a session restart when a rebuilt server has to be picked up.
- **A spike proves a theory; the deliverable is built with MCP tools.** A throwaway dotnet project under
`usecases/<case>/` (referencing `Kompas.Mcp.Core`) is a legitimate way to measure something or falsify a
claim — but the final part/drawing must be produced by MCP tool calls. A runner bypasses the very layer
under test (tool schemas, input parsing, answer shape, auto-validation), so "built by the runner" proves
nothing about the server. Missing tools are the finding, not a reason to finish by hand; delete the
probe project afterwards and carry its numbers into the case's `plan.md` and the skill.
- **Skills are debugged like code.** `kompas-3d` and `kompas-fdm-design` are objects of revision, not fixed
inputs. Edit them **only** in `plugin/skills/<name>/` (the source of truth — the other trees are junctions);
a skill edit needs no rebuild but is only picked up by a *new* session.
- **Nothing internal leaks into a published skill** — no local SDK paths, no `usecases/`, no RAG-base
references. Internal findings belong in `kompas-mcp-dev`; only reproducible methodology ships.
- **The operation log is the fourth channel — evidence instead of recollection.** Every tool call can be
appended to a JSONL file (`Core/Diagnostics/OperationLog`, written from the `CallToolFilter` in
`Program.cs`): tool, arguments, duration, ok/error text, the first 500 chars of the answer. It writes
**only** when a path is given (`KOMPAS_MCP_OPLOG_PATH` at startup or `set_operation_log(enabled, path)`
in session) — no path, no file. `tools/dev/oplog-report.ps1` turns the accumulated history into what
decides the catalog's shape: call frequency, failure rate per tool, the most common error texts, and
the tools nobody has ever called (candidates for removal). A tool that only ever fails or never gets
called is a finding about the catalog, not about the task that met it.
### The rebuild cycle
A running server holds its own binary: with `.mcp.json` pointing straight at
@@ -146,7 +170,14 @@ junction by the `ReparsePoint` attribute rather than by resolving the target, wh
still works once the target is gone. No `post-checkout` hook on purpose — it fires after checkout already
broke, too late to help.
## Delegation subagent
## Delegation subagents
**`cad-engineer`** (`.claude/agents/`, model inherit — Skill/Read/Glob/Grep + the `kompas` MCP server,
no write access): runs a whole CAD task end-to-end from a clean context, building **only** through MCP
tools. It is a **debugging harness**, not a shortcut: this session fixes the skills and the server, then
re-runs the same task through the subagent and reads its protocol — calls made, dead ends, complaints
against the skill (quoted), complaints against the catalog. It can't patch anything, so a missing tool
comes back as a finding instead of a hand-made workaround. Project-local, never shipped in the plugin.
**`kompas-sdk-research`** (`.claude/agents/`, model **Haiku**, read-only — MCP `kompas-sdk` tools only):
finds an interface/method/enum/constant signature in the SDK knowledge base and returns a compressed
+50 -10
View File
@@ -2,7 +2,7 @@
**MCP-сервер для управления CAD-системой КОМПАС-3D (АСКОН) языковой моделью.**
Сервер — COM-клиент КОМПАС-3D: подключается к установленному КОМПАС и даёт LLM 56 инструментов для
Сервер — COM-клиент КОМПАС-3D: подключается к установленному КОМПАС и даёт LLM 60 инструментов для
создания документов, построения 3D-деталей (эскиз → формообразующая операция), работы со сборками и
чертежами, импорта/экспорта STEP, прямого редактирования импортированной B-rep, а также структурного
осмотра модели (`describe_model` — дерево операций, тела, топология, переменные, МЦХ одним вызовом)
@@ -61,14 +61,14 @@ kompas-mcp.exe --version # версия бинаря, КОМПАС для эт
}
```
## Инструменты (56)
## Инструменты (60)
| Группа | Инструменты |
|---|---|
| System | `kompas_connect`, `kompas_status`, `kompas_set_visible`, `set_auto_validate` |
| Documents | `document_create`, `document_open`, `document_save`, `document_close`, `document_active` |
| System | `kompas_connect`, `kompas_status`, `kompas_set_visible`, `set_auto_validate`, `set_operation_log` |
| Documents | `document_create`, `document_open`, `document_save`, `document_close`, `document_active`, `set_part_info` |
| Sketch | `sketch_create`, `sketch_add`, `sketch_close` |
| Features | `extrude`, `revolve`, `fillet_edge`, `chamfer_edge`, `shell`, `rib`, `sweep`, `loft`, `pattern`, `mirror`, `hole`, `draft`, `rebuild` |
| Features | `extrude`, `revolve`, `primitive`, `fillet_edge`, `chamfer_edge`, `shell`, `rib`, `sweep`, `loft`, `pattern`, `mirror`, `hole`, `draft`, `feature_delete`, `rebuild` |
| Edit | `move_face`, `split_solid_by_plane`, `move_body`, `boolean_union` |
| Inspection | `describe_model`, `list_features`, `list_bodies`, `list_variables`, `measure` |
| Vision | `model_snapshot` (PNG-снимок; сначала — `describe_model`) |
@@ -82,8 +82,24 @@ kompas-mcp.exe --version # версия бинаря, КОМПАС для эт
Инструменты сгруппированы по смыслу операции, а не по способу вызова: тип задаётся параметром
(`extrude(mode=boss|cut)`, `hole(type=simple|counterbore|countersink|conic)`,
`pattern(kind=linear|circular)`), а объект — либо индексом из `list_faces`/`list_edges`,
либо точкой в мировых координатах. Эскиз строится списком примитивов за один вызов:
`sketch_create(plane|faceIndex, entities[])`.
либо точкой в мировых координатах (рёбра под скругление/фаску — и списком точек `points[]`,
чтобы N углов давали одну операцию). `list_edges` отбирает рёбра по типу и координатному окну —
так адресуют «весь нижний контур» на теле, где рёбер сотни. Эскиз строится списком примитивов за один вызов:
`sketch_create(plane|faceIndex, entities[])`. У каждой операции есть `name` — имя узла в дереве
построения; без него дерево состоит из безликих «Эскиз:1», «Элемент выдавливания:2».
Формообразование идёт двумя путями, и их сочетают: `primitive(kind=block|cylinder|sphere|cone,
result=new|union|subtract|intersect)` строит тело по размерам без эскиза (в том числе вычитает
карман или паз), а `sketch_create` + `extrude` нужен там, где контур сложный — текст, кривые,
произвольный профиль.
Примитивы эскиза: `line`, `circle`, `rectangle`, `arc`, `arc3points`, `ellipse`, `polyline`,
`polygon`, `spline`, `point`, `axis`, `text`. Надпись (`type=text` с `text`, `height`, `fontName`)
сразу переводится в кривые, поэтому её можно выдавливать — так делаются логотипы и гравировка;
в ответе возвращается габарит глифов (и отдельно ширина ячейки строки), а `align`/`vAlign` кладут
надпись центром или краем ровно в заданную точку. Параметры
`extrude(thinThickness, thinSide)` превращают контур в стенку заданной толщины: `outward`
наращивает её наружу и даёт ободок-эквидистанту вокруг контура (например, подложку под надпись).
### Типовой сценарий
@@ -99,6 +115,30 @@ kompas-mcp.exe --version # версия бинаря, КОМПАС для эт
> `validate_part` после каждого шага не нужен. Отключается на сессию через `set_auto_validate(false)`
> или при старте сервера переменной окружения `KOMPAS_MCP_AUTOVALIDATE=0`.
### Журнал операций
Сервер умеет записывать каждый вызов инструмента в файл JSON Lines — по строке на вызов: время,
имя инструмента, аргументы, длительность, успех или полный текст ошибки, начало ответа (снимки
модели не пишутся, только их размер). Журнал — материал для решений о самом каталоге: что зовут,
что стабильно отказывает, что не зовут никогда.
```powershell
$env:KOMPAS_MCP_OPLOG_PATH = "D:\logs\kompas-operations.jsonl" # путь = включение
$env:KOMPAS_MCP_OPLOG = "0" # временно заглушить, путь не стирая
```
Без пути журнал выключен: сервер не пишет ничего и никуда. В сессии режим переключается
инструментом `set_operation_log(enabled, path?)`, состояние видно в `kompas_status`. Сбой записи
никогда не роняет операцию — причина оседает в статусе. Сводка по накопленному:
```powershell
pwsh -NoProfile -File tools/dev/oplog-report.ps1 -Path D:\logs\kompas-operations.jsonl
```
Отчёт даёт частоту вызовов и медианное время по инструментам, долю отказов, самые частые тексты
ошибок и список инструментов, ни разу не встретившихся в журнале (каталог берётся из атрибутов
`McpServerTool` в исходниках).
> ⚠️ Вызовы зависимых инструментов выполняйте **последовательно**, дождавшись ответа: сессия
> построения хранит эскизы по `id`, а сервер обрабатывает запросы конкурентно.
@@ -108,7 +148,7 @@ kompas-mcp.exe --version # версия бинаря, КОМПАС для эт
src/Kompas.Mcp.Core/ COM-слой: STA-диспетчер, подключение, документы, эскизы/операции,
сборки, чертежи, конвертация, снимок, инспекция модели
src/Kompas.Mcp.Host/ MCP-сервер (stdio) + определения инструментов (Tools/ — по группе на файл)
tests/Kompas.Mcp.Tests/ 427 тестов: unit (корень) + Integration/ (нужен запущенный КОМПАС)
tests/Kompas.Mcp.Tests/ 567 тестов: unit (корень) + Integration/ (нужен запущенный КОМПАС)
libs/kompas-interop/ interop-сборки КОМПАС (из SDK Samples/Common) — только для компиляции
plugin/ плагин Claude Code `kompas`: манифест, лаунчер, лок версии сервера,
навыки kompas-3d и kompas-fdm-design (источник истины)
@@ -132,8 +172,8 @@ dotnet test --filter "Category=Integration" # только integration
pwsh -NoProfile -File tools/tests/run-ps-tests.ps1 # Pester-тесты скриптов плагина
```
427 тестов: 291 unit + 136 integration. CI (Gitea Actions, `.gitea/workflows/ci.yml`) собирает проект и
гоняет `Category=Unit&Requires!=Windows` на Linux-раннере (245 тестов) — 5 тестов STA-диспетчера
567 тестов: 406 unit + 161 integration. CI (Gitea Actions, `.gitea/workflows/ci.yml`) собирает проект и
гоняет `Category=Unit&Requires!=Windows` на Linux-раннере (401 тест) — 5 тестов STA-диспетчера
помечены `Requires=Windows` (`Thread.SetApartmentState` не работает на Linux). Integration-тесты в CI
не запускаются никогда: нужен запущенный КОМПАС с GUI и лицензией. Плюс 41 Pester-тест
PowerShell-скриптов плагина и раскладки навыков — в счёт .NET-тестов не входят.
+145 -8
View File
@@ -47,9 +47,9 @@ MCP-сервер даёт **общие** операции КОМПАС (эски
**Реализованы (56 инструментов).** Инструмент назван по СМЫСЛУ операции, а вид задаётся параметром —
не ищи отдельный инструмент под каждый вариант:
- *Система/документы:* `kompas_connect`, `kompas_set_visible`, `kompas_status`, `set_auto_validate`; `document_create|open|save|close|active` (`document_save(path)` — сохранить новый документ или копию, без `path` — на прежнее место).
- *Эскиз (три инструмента вместо шестнадцати):* `sketch_create` — основание задаётся `plane` (+`offset`) ЛИБО `faceIndex` ЛИБО точкой `x,y,z`, а геометрия сразу списком `entities[{type: line|circle|rectangle|arc|arc3points|ellipse|polyline|polygon|spline|point|axis, …}]`; `sketch_add` — дополнить открытый эскиз; `sketch_close`.
- *Формообразующие:* `extrude(mode=boss|cut)`, `revolve(mode=boss|cut)`, `fillet_edge`/`chamfer_edge` (список `edgeIndices`одной операцией на все рёбра — либо точка), `shell`, `rib`, `sweep`, `loft`, `draft`, `rebuild`.
- *Система/документы:* `kompas_connect`, `kompas_set_visible`, `kompas_status`, `set_auto_validate`; `document_create|open|save|close|active` (`document_save(path)` — сохранить новый документ или копию, без `path` — на прежнее место); `set_part_info(name, marking)` — наименование и обозначение детали.
- *Эскиз (три инструмента вместо шестнадцати):* `sketch_create` — основание задаётся `plane` (+`offset`) ЛИБО `faceIndex` ЛИБО точкой `x,y,z`, а геометрия сразу списком `entities[{type: line|circle|rectangle|arc|arc3points|ellipse|polyline|polygon|spline|point|axis|text, …}]`; `sketch_add` — дополнить открытый эскиз; `sketch_close`.
- *Формообразующие:* `primitive(kind=block|cylinder|sphere|cone, result=new|union|subtract|intersect)` — тело по размерам БЕЗ эскиза, `extrude(mode=boss|cut)`, `revolve(mode=boss|cut)`, `fillet_edge`/`chamfer_edge` (одной операцией на все рёбра: список `edgeIndices` ЛИБО список `points[{x,y,z}]`, либо одна точка), `shell`, `rib`, `sweep`, `loft`, `draft`, `feature_delete` (убрать операцию/эскиз из дерева, зависимые уходят каскадом), `rebuild`. У всех — `name`: имя операции в дереве построения.
- *Отверстия:* `hole(type=simple|counterbore|countersink|conic)` — грань по `faceIndex` (центр грани) либо точкой.
- *Массивы и зеркало:* `pattern(kind=linear|circular)`, `mirror` (без `featureIds` — всё тело, с ними — только эти операции).
- *Прямое редактирование (без дерева, в т.ч. импортированная B-rep):* `move_face` (сдвинуть грань на N мм по нормали; грань — `faceIndex` или точка), `split_solid_by_plane` (рассечь тело плоскостью), `move_body` (сдвинуть тело на вектор), `boolean_union` (объединить тела).
@@ -57,12 +57,49 @@ MCP-сервер даёт **общие** операции КОМПАС (эски
- *Обмен:* `import_step`, `export_step`.
- *Сборка:* `assembly_add_component`, `assembly_add_mate`.
- *2D-чертёж:* `drawing_create_standard_views`, `drawing_fill_title_block`, `drawing_set_sheet_format`, `drawing_add_linear|diametral|radial|angular_dimension`, `drawing_add_rough`, `drawing_add_text`, `drawing_add_leader`, `drawing_set_technical_requirements`.
- *Запрос/осмотр:* `describe_model` (структурный паспорт; `sections=box,mass,bodies,topology,tree,variables` сужает ответ и чтение), `list_faces`/`list_edges` (без `index` — список, с `index` — подробности объекта), `list_components`, `list_bodies`, `list_features`, `list_variables`, `measure`, `model_snapshot`.
- *Запрос/осмотр:* `describe_model` (структурный паспорт; `sections=box,mass,bodies,topology,tree,variables` сужает ответ и чтение), `list_faces`/`list_edges` (без `index` — список, с `index` — подробности объекта; у `list_edges` есть отбор `type` и окно `minX…maxZ` — им адресуют то, что не выбрать по одному: `list_edges(minZ=-0.01, maxZ=0.01)` даёт весь нижний контур под фаску), `list_components`, `list_bodies`, `list_features`, `list_variables`, `measure`, `model_snapshot`.
- *Проверка:* `validate_part` — нужен, только если авто-проверка выключена (см. правило 2).
Набор растёт от версии к версии — актуальный перечень с описаниями параметров отдаёт сам
MCP-сервер; если инструмента под задачу нет, собирай результат из имеющихся общих операций.
## Два пути формообразования — выбирай по форме, а не по привычке
«Эскиз → выдавливание» — не единственный способ. `primitive` строит тело сразу по размерам, и это
короче и надёжнее там, где форма призматическая:
| Форма | Чем строить |
|---|---|
| Плита, брусок, бобышка, штифт, цилиндрическая стойка | `primitive(kind=block\|cylinder)` — один вызов вместо «эскиз + выдавливание» |
| Прямоугольный карман, паз, срез угла | `primitive(..., result=subtract)` — вычитание тела вместо `extrude(mode=cut)` по эскизу |
| Скруглённые углы призмы | `primitive` + `fillet_edge` по рёбрам (скругление после, а не дуги в эскизе) |
| Текст, кривые, произвольный контур, переменное сечение | ТОЛЬКО эскиз: `sketch_create(entities=[…])` + `extrude`/`revolve`/`loft`/`sweep` |
| Тонкая стенка, ободок по контуру | эскиз + `extrude(thinThickness, thinSide)` |
Их **сочетают в одной детали**: корпусные объёмы — примитивами, сложные контуры — эскизами.
Точка привязки у `block`**угол** (не центр), у тел вращения — центр основания; размеры идут по
осям X/Y/Z текущей системы координат.
- **Тонкую стенку по сложному контуру строй ДО того, как появится тело, которое она пересечёт.**
`extrude(thinThickness=…)` по многоконтурному эскизу (надпись — это десяток замкнутых контуров с
внутренними «дырками») ОТКАЗЫВАЕТ, если стенка пересекает уже построенное тело. Разложено
экспериментом: в пустой детали — строится; тело есть, но стенка его не задевает — строится;
стенка пересекает тело — отказ. Ни число тел, ни то, чем тело создано (эскизом или примитивом),
роли не играют, а простой прямоугольный контур проходит и с пересечением. Поэтому на шильдике
«плашка → буквы → кайма» падает, а «буквы → кайма → плашка `primitive(union)` → карман
`primitive(subtract)`» проходит: примитивам пересечение безразлично.
- **Скругления делай, пока рёбер мало** — сразу после примитивов их видно в `list_edges` наперечёт.
Если момент упущен и рёбер сотни, бери рёбра ПО ТОЧКЕ: `fillet_edge(radius, x, y, z)` на углу
известных координат — по одному ребру за вызов, зато без перебора индексов.
- **Скругление не построится там, где ребро «съедено» соседним элементом.** У шильдика угол плашки,
накрытый каймой букв, не скругляется ни R0.5, ни R0.3 — там уже нет ребра. Это нормальный ответ
геометрии, а не ошибка вызова: проверь снимком, что угол вообще виден снаружи.
- **Вычитание проверяется объёмом.** `primitive(result=subtract)` мимо тела КОМПАС считает удачей;
инструмент это ловит и сообщает, но привычку сверять `describe_model(sections="mass")` не отменяет.
- **`result=new` даёт ОТДЕЛЬНОЕ тело.** Оно объединится с остальными, только если последующая
операция их пересечёт; иначе в детали останется несколько тел — проверяй `list_bodies` и при
необходимости зови `boolean_union`.
## Базовый цикл (эскиз → операция → осмотр)
Опорный сценарий построения, проверен end-to-end:
@@ -73,10 +110,104 @@ MCP-сервер; если инструмента под задачу нет, с
4. `extrude(mode="boss", depth=…)` / `revolve(mode="boss", …)` → читаешь итог проверки прямо в ответе (правило 2).
5. **Осмотр — структурно:** `describe_model` (паспорт: габарит, МЦХ, топология, дерево); нужен кусок — `describe_model(sections="box,mass")`. Снимок — только если нужен визуальный контроль.
6. Итерация «на грани»: `list_faces` (при нужде `list_faces(index=N)` за подробностями) → `sketch_create(faceIndex=N, entities=[…])` → операция.
7. **Назови деталь**`set_part_info(name=…)``marking`, если известно обозначение). Без этого в дереве
стоит безликая «Деталь», и она же уедет в штамп чертежа и в спецификацию; `describe_model` специально
отмечает деталь без наименования. Имя файла при сохранении этого НЕ заменяет — это разные свойства.
**Называй каждую операцию.** У `sketch_create`, `extrude`, `primitive`, `fillet_edge`, `hole`,
`pattern`, `shell` и остальных есть `name` — имя узла в дереве построения. Без него дерево выглядит
как «Эскиз:1, Эскиз:2, Элемент выдавливания:3», и по нему нельзя понять, что чем построено, —
ни человеку, ни тебе самому через сотню вызовов. Имя пиши по смыслу: «Контур плашки», «Рельеф букв
OldMan», «Карман под площадку», «Скругления углов плашки».
**Не разбивай построение на лишние вызовы.** Прямоугольник с четырьмя отверстиями — это ОДИН
`sketch_create` со списком из пяти примитивов, а не шесть вызовов. Скругление восьми рёбер — один
`fillet_edge(radius, edgeIndices=[…])`, а не восемь. Каждый лишний вызов — лишний шанс сбиться.
`fillet_edge(radius, edgeIndices=[…])` или `fillet_edge(radius, points=[…])`, а не восемь.
Каждый лишний вызов — лишний шанс сбиться.
## Надписи, логотипы и рельеф
Надпись в эскизе — примитив `type=text` (`points:[{x,y}]` — левый край базовой линии, `text`,
`height` в мм, `fontName` — любой установленный в системе шрифт, плюс `bold`/`italic`,
`widthFactor` — сужение, `angle` — наклон строки). Она **сразу переводится в кривые**, поэтому
выдавливается как обычный контур: гравировка — `extrude(mode="cut")`, выпуклые буквы — `mode="boss"`.
- **Ответ даёт ГАБАРИТ ГЛИФОВ — по нему и позиционируй.** «надпись «OldMan» — глифы X 1.00…87.93,
Y 12.14…28.94 (86.93 × 16.80 мм), ширина ячейки 94.57 мм». Первые числа — прямоугольник, реально
занятый буквами (совпадает с тем, что получится после выдавливания); *ширина ячейки* — шаг строки
с боковыми просветами, она больше на 1–2 % у наборных шрифтов (Zilla Slab, Bevan) и на 79 % у
скриптовых (Lobster, Pacifico). Вписываешь надпись в поле — считай по габариту, не по ячейке.
- **Выравнивание — параметрами, а не пересчётом.** `align: left|center|right` и
`vAlign: baseline|bottom|middle|top` трактуют точку вставки как якорь: `align="center"` кладёт
середину глифов ровно в заданный X. Сервер делает это по фактическому габариту (строит,
замеряет, сдвигает), поэтому цикл «вставил → прочитал → переставил» агенту больше не нужен.
`baseline` — умолчание и поведение самого КОМПАС: выносные элементы уходят ниже точки.
- **Мерить дешевле в черновом документе.** Пока деталь пуста, габарит надписи — это габарит всей
модели, и подбор кегля идёт без пересборки: `document_create part` → эскиз с текстом → прочитать
метрики → закрыть без сохранения. В собранном теле (сотни граней) выделить рельеф уже нечем.
Неудачную пробу теперь не обязательно уносить вместе с документом — `feature_delete(id)`
убирает эскиз или операцию из дерева (зависимые уходят каскадом).
- **`height` — это высота ПРОПИСНОЙ (cap height), а не габарит строки.** Замер на Zilla Slab при
`height=10`: «HH» → 9.97 мм, «hd» → 10.71 (восходящие выше прописной), «Hy» → 13.24 мм
(нижний выносной уходит на −3.27). Считаешь компоновку — закладывай выносные отдельно.
- **`widthFactor` меняет пропорцию крупно, `height` — точно.** Замер (Lobster «OldMan», h=17):
wf 1.00 → 71.84 мм, wf 1.25 → 92.83 (+29 %) — то есть это настоящий масштаб ширины, и без него
скриптовый шрифт в вытянутое поле не вписать. Но КОМПАС **квантует его ступенями**: wf 1.25,
1.26, 1.27, 1.28 дают ячейку 100.64 и ширину 92.83…93.14 (+0.3 %), а на 1.29 ячейка прыгает на
103.34 и ширина сразу 95.49 (+2.5 %). Отсюда правило: пропорцию задаёшь `widthFactor`, точную
ширину добираешь `height` (он линеен до 0.01 мм) — подгонка через wf топчется на ступени.
- **Кайма на острых терминалах шире `thinThickness` — и по каждой стороне по-своему.** Стенка
`outward` обходит контур с миттером: на остром окончании глифа габарит растёт **на сторону** на
`t / sin(θ/2)`, а не на `t` (Lobster: слева +0.90 при t=0.9, справа +1.70 — терминал «n»).
Величина принадлежит не шрифту, а КОНКРЕТНОМУ начертанию: растянув тот же Lobster
(`widthFactor` 1.29), терминал заострился и правый прирост стал +1.99. Поэтому переносить число
между вариантами нельзя — **перезамеряй габарит после смены `widthFactor`**.
- **Кегль под заданный габарит считается за ОДНУ пробу, а не подбирается.** При фиксированном
`widthFactor` прирост каймы Δ (сумма по обеим сторонам) от кегля не зависит — фигура подобна.
Значит: построил пробу с любым `height₀`, замерил ширину глифов `W₀` и суммарный прирост Δ,
дальше `height = height₀ · (W_цель Δ) / W₀` — и целевой габарит получается с первого раза.
Замер: проба h=17.06 дала Δ=4.62 (слева 1.50, справа 3.12 — миттер ×2.08) → h=17.00 → 89.98 мм.
- **Разрядки (трекинга) у примитива нет — её набирают пробелами.** Ширину пробела для конкретного
шрифта и кегля вычисляют двумя пробами: «EDITION» → 38.38 мм, «E D I T I O N» → 47.11 мм,
значит пробел 1.455 мм (Bevan, cap 5.5).
- **Подложка под надпись — тонкой стенкой, а не вторым эскизом.** `extrude(..., thinThickness=1.2,
thinSide="outward")` по контуру букв даёт ободок-эквидистанту вокруг них; выдавь ТОТ ЖЕ эскиз
дважды — сплошным на высоту подложки и стенкой наружу — и получишь силуэт надписи с равномерной
каймой. `inward` съедает контур внутрь (рамка по периметру плашки), `both` делает контур средней
линией стенки. **Проверяй стенку по объёму:** если толщина не применилась, КОМПАС молча строит
сплошное сечение — габарит при этом тот же, и по нему подмену не увидеть.
- **Кайма вокруг ПРЯМОУГОЛЬНОЙ плашки — вторым примитивом, а не тонкой стенкой.** Стенка обходит
контуры эскиза, а плашка по playbook'у строится `primitive`, и каймы у неё не появится. Клади под
неё второй блок на `2·t` больше и на высоту подложки (85 × 13.5 × 2 под плашкой 82 × 10.5 × 3) —
получается тот же кант. Сводить прямоугольник в один эскиз с буквами не надо: контуры пересекутся,
и эскиз станет невалидным.
- **Рельеф = разница глубин.** Основание 2 мм + буквы 3 мм от той же плоскости: буквы выступают
на 1 мм. Не строй буквы «на грани основания» — из одного эскиза на базовой плоскости получается
и то и другое, и рельеф не зависит от порядка операций.
- **Проверяй объёмом, а не габаритом.** Буквы дают сотни NURBS-рёбер, и ни снимок, ни габарит не
покажут, что операция построила не то: сравнивай ПРИРОСТ объёма (`describe_model(sections="mass")`)
с прикидкой «площадь контура × высота». Именно так ловится молча не применившаяся тонкая стенка.
- **Скруглять ВЕРХНИЕ рёбра рельефа пакетом нельзя, а снимать фаску по нижнему контуру — можно.**
Проверено на Zilla Slab «AB» (203 ребра): одиночное верхнее ребро скругляется R0.1 и R0.2, а
`fillet_edge` сразу по всем отказывает при обоих радиусах — соседние скругления конфликтуют.
Зато `chamfer_edge(0.3, edgeIndices=[…])` по **59 рёбрам** нижнего силуэта надписи (24 прямых,
32 NURBS, 3 дуги, включая огрызки 0.03 мм) строится с первой попытки: фаска против «слоновьей
ноги» по всему контуру — это ОДНА операция. Рёбра нижнего контура берутся окном:
`list_edges(minZ=-0.01, maxZ=0.01)`.
- **`primitive(subtract)` режет ВСЁ, что попало в его габарит — включая уже построенные буквы.**
Сверять надо **глифы, а не кайму**, и только те, что дотягиваются до кармана ПО ВЫСОТЕ: карман
обычно вычитается от z основания вверх (например от 2 до 3), а кайма ростом всего 2 мм в этот
слой не попадает — она может сколько угодно перекрывать плашку в плане, и именно это перекрытие
держит деталь одним телом. Опасны буквы: они идут на всю высоту 3 мм, поэтому их нижняя кромка
по Y должна быть выше верхней кромки кармана. Проверка постфактум — **точное равенство убыли
объёма** расчётному (79 × 7.5 × 1.0 = 592.5 против фактических 592.5 мм³): любое касание соседней
геометрии сразу ломает равенство, а габарит и снимок его не покажут.
- **Углы вокруг надписи — одним `fillet_edge(points=[…])`.** На теле с сотнями рёбер список из
`list_edges` неподъёмен, но точки углов известны из построения: передай их списком, и все рёбра
уйдут в ОДНУ операцию дерева. Если ребра в части точек нет (типичный случай — угол «съеден»
каймой букв), ошибка перечислит ВСЕ такие точки сразу: убери их и повтори одним вызовом.
- **Шрифт обязан быть установлен в системе**, где работает КОМПАС: неизвестное имя молча
подменяется другим шрифтом, и надпись «поедет» по ширине без единого сообщения об ошибке.
## Работа с импортом / сборками
@@ -90,9 +221,10 @@ MCP-сервер; если инструмента под задачу нет, с
компоненты пишутся как отдельные `.m3d` рядом; открывать деталь самостоятельным документом
(`OpenSourceDocument`). Гашение видимости компонента на снимок **не влияет** — изоляция так не делается.
4. **Осмотр детали — структурно:** `describe_model` (габарит по осям → какая ось «высота», МЦХ,
тела, топология). Грани/рёбра под операцию — `list_faces`/`list_faces(index=N)`, `measure`. `model_snapshot`
(или стандартные проекции `#Спереди/#Сверху/#Слева/#Справа/#Изометрия`)**только** если нужен
визуально-пространственный контроль формы.
тела, топология). Грани/рёбра под операцию — `list_faces`/`list_faces(index=N)`, `measure`.
`model_snapshot` — **только** если нужен визуально-пространственный контроль формы; выбора вида
у него нет (снимок идёт с текущей ориентации окна, параметры — только разрешение и оттенки серого),
так что фронтальный контроль плоской детали им не сделать: сверяй числа.
5. **Модификация** на «тупой» импортированной B-rep (итог проверки читаешь в ответе каждой операции):
- *Простой случай* — `move_face`: сдвинуть плоскую грань на +N мм (наружу) или −N (внутрь);
грань бери по `faceIndex` из `list_faces`. ⚠ На сложном торце (с отверстием/пазом) FaceMover
@@ -118,6 +250,11 @@ MCP-сервер; если инструмента под задачу нет, с
сверяй результат числами (`describe_model(sections="box,mass")`), снимок лишь при необходимости.
- **МЦХ.** Объём/масса меняющейся геометрии — `describe_model(sections="mass")`; перед чтением после правок — `rebuild`.
- **id эскизов/операций** недействительны после смены активного документа (create/open/close).
- **Имя файла занимает открытый документ.** Если та же деталь уже открыта в КОМПАС (например, прошлая
версия из этой же папки), `document_save(path)` по этому пути не пройдёт — КОМПАС не перезаписывает
занятый файл. Освободить имя: `document_close(all=true)`. Проверяй, что файл на диске реально
обновился (время изменения), особенно когда перестраиваешь деталь поверх прежней.
Несуществующий каталог в пути — не помеха: `document_save` и `export_step` создают его сами.
- **Единицы — мм** (геометрия) и кг (масса). Локальные координаты эскиза ≠ мировые координаты модели.
## Открытые вопросы / границы
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Threading;
@@ -88,8 +88,8 @@ public sealed class ConversionService
private void ExportCore(string path, StepFormat format)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Путь экспорта не задан.", nameof(path));
// Каталог создаём сами: конвертер в несуществующую папку возвращает FALSE без причины.
var full = Documents.OutputPath.Prepare(path);
var app = _session.Application;
object? active = null;
@@ -110,8 +110,8 @@ public sealed class ConversionService
?? throw new InvalidOperationException("ConverterParameters вернул не IAdditionConvertParameters.");
prm.Format = (ksKOMPASConverterEnum)formatCode;
if (!doc3d1.ConvertToAdditionFormat(path, (AdditionConvertParameters)prm))
throw new InvalidOperationException("ConvertToAdditionFormat вернул FALSE (экспорт STEP не удался).");
if (!doc3d1.ConvertToAdditionFormat(full, (AdditionConvertParameters)prm))
throw new InvalidOperationException($"ConvertToAdditionFormat вернул FALSE (экспорт STEP по пути «{full}» не удался).");
}
finally
{
@@ -0,0 +1,145 @@
using Kompas.Mcp.Core.Documents;
namespace Kompas.Mcp.Core.Diagnostics;
/// <summary>
/// Журнал операций: дописывает в файл по строке JSON на каждый вызов инструмента. Нужен, чтобы
/// накапливать историю работы агента с сервером — по ней потом видно, какие инструменты реально
/// зовут, какие падают и какие можно убрать или переделать.
/// <para>Включается путём: <see cref="OperationLogSettings.PathVariable"/> на старте или
/// <c>set_operation_log</c> в сессии. Без пути журнал молчит.</para>
/// <para>Сбой записи никогда не ломает вызов инструмента: причина запоминается в
/// <see cref="LastError"/> (её показывают <c>kompas_status</c> и <c>set_operation_log</c>),
/// а операция продолжается как ни в чём не бывало — журнал диагностический, а не рабочий.</para>
/// </summary>
public sealed class OperationLog
{
private readonly object _gate = new();
private volatile bool _enabled;
private string? _path;
private int _written;
private string? _lastError;
public OperationLog()
: this(OperationLogSettings.ReadFromEnvironment())
{
}
public OperationLog(OperationLogOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (!options.Enabled || options.Path is null) return;
// Каталог создаём на старте: иначе первая же запись обнаружит проблему уже в фильтре,
// где её никто не увидит.
try
{
_path = OutputPath.Prepare(options.Path, OperationLogSettings.PathVariable);
_enabled = true;
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
_lastError = ex.Message;
}
}
/// <summary>Пишется ли журнал сейчас.</summary>
public bool Enabled => _enabled;
/// <summary>Файл журнала (абсолютный путь) или <c>null</c>, если путь ни разу не задавали.</summary>
public string? Path
{
get { lock (_gate) return _path; }
}
/// <summary>Сколько записей ушло в файл с момента старта сервера.</summary>
public int WrittenCount
{
get { lock (_gate) return _written; }
}
/// <summary>Причина последнего сбоя (записи или включения) — или <c>null</c>.</summary>
public string? LastError
{
get { lock (_gate) return _lastError; }
}
/// <summary>
/// Включить журнал. Путь берётся из параметра, иначе — из уже настроенного, иначе — из
/// окружения. Каталог назначения создаётся.
/// </summary>
/// <exception cref="ArgumentException">Путь не задан ни одним из способов или некорректен.</exception>
/// <returns>Файл, в который пойдёт запись.</returns>
public string Enable(string? path = null)
{
lock (_gate)
{
var target = OperationLogSettings.ResolveFile(path)
?? _path
?? OperationLogSettings.ResolveFile(
Environment.GetEnvironmentVariable(OperationLogSettings.PathVariable));
if (target is null)
{
throw new ArgumentException(
"Путь к файлу журнала не задан: укажите параметр path или переменную окружения " +
OperationLogSettings.PathVariable + ".", nameof(path));
}
_path = OutputPath.Prepare(target, nameof(path));
_lastError = null;
_enabled = true;
return _path;
}
}
/// <summary>Выключить журнал; путь запоминается, повторное включение обойдётся без него.</summary>
public void Disable()
{
lock (_gate) _enabled = false;
}
/// <summary>
/// Дописать запись о вызове. Быстрый выход, когда журнал выключен, — фильтр зовёт этот метод
/// на каждом вызове инструмента.
/// </summary>
public void Write(OperationLogEntry entry)
{
if (!_enabled || entry is null) return;
lock (_gate)
{
if (!_enabled || _path is null) return;
try
{
File.AppendAllText(_path, entry.ToJson() + Environment.NewLine);
_written++;
_lastError = null;
}
catch (Exception ex)
{
// Диагностика не имеет права уронить операцию — ни сбоем файла, ни неожиданным
// значением в аргументах.
_lastError = ex.Message;
}
}
}
/// <summary>Строка о состоянии журнала для ответов инструментов.</summary>
public string Describe()
{
lock (_gate)
{
var problem = _lastError is null ? "" : $" Последняя ошибка записи: {_lastError}.";
if (!_enabled)
{
return _path is null
? "Журнал операций выключен."
: $"Журнал операций выключен (записано за сессию: {_written}, файл {_path})." + problem;
}
return $"Журнал операций пишется в {_path} (записей за сессию: {_written})." + problem;
}
}
}
@@ -0,0 +1,135 @@
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
namespace Kompas.Mcp.Core.Diagnostics;
/// <summary>
/// Одна строка журнала операций — вызов инструмента: что звали, с чем, сколько заняло и чем
/// кончилось. Формат — JSON Lines (объект на строку), чтобы историю за месяцы можно было читать
/// потоком и грепать без парсера.
/// <para>Ответ пишется усечённым (<see cref="ResultLimit"/>): цель журнала — понять, какие
/// инструменты используются и на чём спотыкаются, а не хранить полный вывод <c>describe_model</c>.
/// Текст ошибки, наоборот, сохраняется целиком — ради него журнал и заводится.</para>
/// </summary>
public sealed record OperationLogEntry
{
/// <summary>Сколько символов ответа сохраняется.</summary>
public const int ResultLimit = 500;
/// <summary>Предел сериализованных аргументов: длинная надпись или пакет из сотен примитивов
/// не должны раздувать строку журнала до мегабайта.</summary>
public const int ArgumentsLimit = 8000;
/// <summary>Момент завершения вызова (UTC).</summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Имя инструмента.</summary>
public required string Tool { get; init; }
/// <summary>Аргументы вызова как их прислал клиент.</summary>
public IReadOnlyDictionary<string, JsonElement>? Arguments { get; init; }
/// <summary>Идентификатор MCP-сессии — по нему вызовы группируются в один прогон.</summary>
public string? Session { get; init; }
/// <summary>Клиент MCP (имя и версия), если он представился.</summary>
public string? Client { get; init; }
/// <summary>Длительность вызова.</summary>
public TimeSpan Duration { get; init; }
/// <summary>Успешен ли вызов (ошибка инструмента — тоже неуспех, не только исключение).</summary>
public required bool Ok { get; init; }
/// <summary>Текст ответа (будет усечён при записи).</summary>
public string? Result { get; init; }
/// <summary>Текст ошибки — тот же, что ушёл агенту.</summary>
public string? Error { get; init; }
private static readonly JsonWriterOptions WriterOptions = new()
{
// Без этого кириллица уезжает в \u04xx и журнал нечитаем глазами.
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = false,
};
/// <summary>Сериализовать запись в одну строку JSON (без перевода строки на конце).</summary>
public string ToJson()
{
using var buffer = new MemoryStream(512);
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
{
writer.WriteStartObject();
writer.WriteString("ts", Timestamp.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
writer.WriteString("tool", Tool);
writer.WriteNumber("ms", (long)Math.Round(Duration.TotalMilliseconds));
writer.WriteBoolean("ok", Ok);
if (!string.IsNullOrEmpty(Session)) writer.WriteString("session", Session);
if (!string.IsNullOrEmpty(Client)) writer.WriteString("client", Client);
WriteArguments(writer);
if (Error is { Length: > 0 })
{
writer.WriteString("error", Error);
}
else if (Result is not null)
{
writer.WriteString("result", Truncate(Result, ResultLimit));
writer.WriteNumber("resultChars", Result.Length);
}
writer.WriteEndObject();
}
return Encoding.UTF8.GetString(buffer.ToArray());
}
private void WriteArguments(Utf8JsonWriter writer)
{
if (Arguments is null || Arguments.Count == 0) return;
var json = SerializeArguments(Arguments);
writer.WritePropertyName("args");
if (json.Length <= ArgumentsLimit)
{
writer.WriteRawValue(json, skipInputValidation: true);
}
else
{
// Усечённый JSON — уже не JSON, поэтому кладём его строкой: анализ увидит и сам факт
// усечения, и начало аргументов.
writer.WriteStringValue(Truncate(json, ArgumentsLimit));
}
}
private static string SerializeArguments(IReadOnlyDictionary<string, JsonElement> arguments)
{
using var buffer = new MemoryStream(256);
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
{
writer.WriteStartObject();
foreach (var (name, value) in arguments)
{
writer.WritePropertyName(name);
// WriteTo переносит значение как есть — без рефлексии и без потери типа.
value.WriteTo(writer);
}
writer.WriteEndObject();
}
return Encoding.UTF8.GetString(buffer.ToArray());
}
/// <summary>Обрезать текст до предела, пометив, сколько символов отброшено.</summary>
public static string Truncate(string text, int limit)
{
ArgumentNullException.ThrowIfNull(text);
if (limit <= 0 || text.Length <= limit) return text;
return text[..limit] + $"…[+{text.Length - limit} симв.]";
}
}
@@ -0,0 +1,71 @@
namespace Kompas.Mcp.Core.Diagnostics;
/// <summary>Режим журнала операций на старте: включён ли и в какой файл писать.</summary>
/// <param name="Enabled">Писать ли вызовы инструментов.</param>
/// <param name="Path">Файл журнала (уже с именем файла, каталог мог быть не создан).</param>
public sealed record OperationLogOptions(bool Enabled, string? Path);
/// <summary>
/// Разбор настроек журнала операций из окружения — отдельно от файловой работы, чтобы покрыть
/// тестами. Журнал никогда не пишется «куда-нибудь сам»: без явного пути
/// (<see cref="PathVariable"/> или параметр <c>set_operation_log</c>) он выключен.
/// </summary>
public static class OperationLogSettings
{
/// <summary>Переменная окружения с путём к файлу (или каталогу) журнала.</summary>
public const string PathVariable = "KOMPAS_MCP_OPLOG_PATH";
/// <summary>Переменная окружения-выключателя: позволяет заглушить журнал, не стирая путь.</summary>
public const string EnabledVariable = "KOMPAS_MCP_OPLOG";
/// <summary>Имя файла, если в настройке указан каталог.</summary>
public const string DefaultFileName = "kompas-operations.jsonl";
/// <summary>
/// Собрать режим из пары значений. Пустой путь — журнал выключен (это умолчание).
/// Выключатель понимает 0 | false | off | no | disabled (регистр не важен), всё прочее —
/// «включено», как и отсутствие переменной.
/// </summary>
public static OperationLogOptions Parse(string? enabled, string? path)
{
var file = ResolveFile(path);
return file is null
? new OperationLogOptions(false, null)
: new OperationLogOptions(ParseEnabled(enabled), file);
}
/// <summary>Прочитать режим из переменных окружения процесса.</summary>
public static OperationLogOptions ReadFromEnvironment()
=> Parse(Environment.GetEnvironmentVariable(EnabledVariable),
Environment.GetEnvironmentVariable(PathVariable));
/// <summary>Значение выключателя: пусто — включено, явные «нет» — выключено.</summary>
public static bool ParseEnabled(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return true;
return value.Trim().ToLowerInvariant() switch
{
"0" or "false" or "off" or "no" or "disabled" => false,
_ => true,
};
}
/// <summary>
/// Привести настройку к пути файла. Путь без расширения или с завершающим разделителем
/// считается каталогом — в него кладётся <see cref="DefaultFileName"/>. Проверка чисто
/// строковая (без обращения к диску): каталога может ещё не быть, его создаст запись.
/// Возвращает <c>null</c>, если путь не задан.
/// </summary>
public static string? ResolveFile(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return null;
var trimmed = path.Trim();
var looksLikeDirectory =
trimmed.EndsWith(Path.DirectorySeparatorChar) ||
trimmed.EndsWith(Path.AltDirectorySeparatorChar) ||
Path.GetExtension(trimmed).Length == 0;
return looksLikeDirectory ? Path.Combine(trimmed, DefaultFileName) : trimmed;
}
}
@@ -53,11 +53,71 @@ public sealed class DocumentService
/// <summary>Сохранить активный документ (для уже сохранённого — без диалога).</summary>
public Task SaveAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => WithActive(d => d.Save()), ct);
=> _dispatcher.InvokeAsync(() => WithActive(d =>
{
d.Save();
if (d.Changed)
throw new InvalidOperationException(
"КОМПАС не сохранил документ: в нём остались несохранённые изменения. " +
"Для нового документа передайте path (Save без пути работает только для ранее сохранённого).");
return true;
}), ct);
/// <summary>Сохранить активный документ по указанному пути (безопасно для новых документов).</summary>
public Task SaveAsAsync(string path, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => WithActive(d => d.SaveAs(path)), ct);
public Task<string> SaveAsAsync(string path, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
// Каталог создаём ДО SaveAs: в отсутствующую папку КОМПАС молча не пишет.
var full = OutputPath.Prepare(path);
return WithActive(d =>
{
d.SaveAs(full);
// SaveAs ничего не возвращает, а КОМПАС молча не сохраняет, если файл с этим именем уже
// открыт другим документом. Без проверки по факту агент получает ложный «Сохранено».
var actual = d.PathName ?? string.Empty;
var samePath = string.Equals(actual, full, StringComparison.OrdinalIgnoreCase);
if (!samePath || !File.Exists(full))
throw new InvalidOperationException(
$"КОМПАС не сохранил документ по пути «{full}»" +
(actual.Length == 0
? " (документ так и остался несохранённым)"
: samePath
? " (путь документу присвоен, но файла на диске нет — проверьте права на запись)"
: $" (документ остался по пути «{actual}»)") +
(samePath
? "."
: ". Обычная причина — файл с этим именем уже открыт в КОМПАС другим документом; " +
"освободите имя через document_close(all=true)."));
return full;
});
}, ct);
/// <summary>
/// Задать наименование и/или обозначение детали активного 3D-документа. Наименование — это имя
/// вершинного компонента: оно стоит в дереве вместо безликой «Детали» и попадает в штамп чертежа
/// и в спецификацию. Пустой аргумент оставляет свойство как есть. Возвращает значения после записи.
/// </summary>
public Task<PartIdentity> SetPartIdentityAsync(string? name, string? marking, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d)
throw new InvalidOperationException("Активный документ не является 3D-документом.");
if (doc3d.TopPart is not IPart7 top)
throw new InvalidOperationException("Не удалось получить вершинный компонент (IPart7).");
try
{
if (name is not null) top.Name = name;
if (marking is not null) top.Marking = marking;
if (!top.Update())
throw new InvalidOperationException("IPart7.Update() вернул FALSE — свойства детали не применены.");
// Read-back: КОМПАС может не принять значение (например, у компонента-заготовки),
// а присваивание свойства об этом не сообщает.
return new PartIdentity { Name = top.Name ?? string.Empty, Marking = top.Marking ?? string.Empty };
}
finally { ComHelper.Release(top); ComHelper.Release(doc3d); }
}, ct);
/// <summary>Закрыть активный документ. <paramref name="save"/> — сохранять ли изменения.</summary>
public Task<bool> CloseAsync(bool save = false, CancellationToken ct = default)
@@ -0,0 +1,52 @@
namespace Kompas.Mcp.Core.Documents;
/// <summary>
/// Подготовка пути, по которому мы собираемся ЧТО-ТО ЗАПИСАТЬ (документ, STEP, снимок).
/// <para>КОМПАС каталогов не создаёт: <c>SaveAs</c> в несуществующую папку просто ничего не пишет
/// и молчит, а <c>ConvertToAdditionFormat</c> возвращает FALSE без причины. Агент в этот момент
/// думает, что имя файла занято, и уходит чинить не то. Поэтому каталог создаём сами, а путь
/// приводим к абсолютному — относительный КОМПАС трактует от своего рабочего каталога,
/// а не от каталога агента.</para>
/// </summary>
public static class OutputPath
{
/// <summary>
/// Привести путь к абсолютному и создать каталог назначения. Возвращает полный путь.
/// </summary>
/// <param name="path">Путь к файлу (абсолютный или относительный).</param>
/// <param name="parameterName">Имя параметра инструмента — попадёт в текст ошибки.</param>
public static string Prepare(string? path, string parameterName = "path")
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Путь к файлу не задан.", parameterName);
string full;
try
{
full = Path.GetFullPath(path);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
throw new ArgumentException($"Некорректный путь «{path}»: {ex.Message}", parameterName, ex);
}
if (Path.GetFileName(full).Length == 0)
throw new ArgumentException($"Путь «{path}» указывает на каталог, а не на файл.", parameterName);
var directory = Path.GetDirectoryName(full);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
{
throw new InvalidOperationException(
$"Каталог «{directory}» не существует и не создаётся: {ex.Message}", ex);
}
}
return full;
}
}
@@ -0,0 +1,11 @@
namespace Kompas.Mcp.Core.Documents;
/// <summary>Наименование и обозначение детали — то, что видно в дереве, штампе и спецификации.</summary>
public sealed record PartIdentity
{
/// <summary>Наименование (имя вершинного компонента; в дереве вместо «Деталь»).</summary>
public required string Name { get; init; }
/// <summary>Обозначение по ЕСКД (может быть пустым).</summary>
public required string Marking { get; init; }
}
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Threading;
using KompasAPI7;
@@ -25,10 +25,10 @@ public sealed class FaceEditService
/// на <paramref name="distance"/> мм вдоль её нормали. Направление подбирается автоматически
/// (наружу — добавление материала); если КОМПАС отвергает направление, пробуется обратное.
/// </summary>
public Task MoveFaceAsync(double x, double y, double z, double distance, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => MoveFaceCore(x, y, z, distance), ct);
public Task MoveFaceAsync(double x, double y, double z, double distance, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => MoveFaceCore(x, y, z, distance, name), ct);
private void MoveFaceCore(double x, double y, double z, double distance)
private void MoveFaceCore(double x, double y, double z, double distance, string? name)
{
if (distance == 0) throw new ArgumentException("Смещение не может быть нулевым.", nameof(distance));
@@ -49,6 +49,8 @@ public sealed class FaceEditService
if (!fm.SetFaces(new object[] { face }, true))
throw new InvalidOperationException("FaceMover.SetFaces отверг грань.");
fm.Offset = Math.Abs(distance);
// Имя ставим до Update(): иначе в дереве останется системное.
if (Modeling.FeatureName.Normalize(name) is { } title && fm is IModelObject named) named.Name = title;
// Направление: пробуем одно, при отказе — обратное (нормаль грани может смотреть в любую сторону).
fm.Direction = distance > 0;
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Threading;
using Kompas6Constants;
@@ -29,7 +29,7 @@ public sealed class SolidEditService
/// Возвращает габариты получившихся тел (с индексами) — по ним выбирают тело для <see cref="MoveBodyAsync"/>.
/// </summary>
public Task<IReadOnlyList<BodyBox>> SplitByPlaneAsync(
BasePlane basePlane, double offset, bool direction = true, CancellationToken ct = default)
BasePlane basePlane, double offset, bool direction = true, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync<IReadOnlyList<BodyBox>>(() =>
{
var (doc3d, top) = ActivePart();
@@ -45,6 +45,9 @@ public sealed class SolidEditService
var mc = ModelContainer(top);
var ss = mc.SplitSolids.Add();
ss.CutObjects = plane;
// Имя ставим до Update(): иначе в дереве останется системное.
if (Modeling.FeatureName.Normalize(name) is { } title && ss is IModelObject named)
named.Name = title;
if (!ss.Update())
throw new InvalidOperationException("SplitSolid.Update вернул FALSE (рассечение не удалось).");
doc3d.RebuildDocument();
@@ -52,7 +55,7 @@ public sealed class SolidEditService
}, ct);
/// <summary>Переместить тело (по индексу из <see cref="SplitByPlaneAsync"/>) на вектор (dx, dy, dz) мм.</summary>
public Task MoveBodyAsync(int bodyIndex, double dx = 0, double dy = 0, double dz = 0, CancellationToken ct = default)
public Task MoveBodyAsync(int bodyIndex, double dx = 0, double dy = 0, double dz = 0, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var (doc3d, top) = ActivePart();
@@ -67,13 +70,16 @@ public sealed class SolidEditService
var pos = br.Position;
pos.SetStartingOrientation();
pos.X = dx; pos.Y = dy; pos.Z = dz; // смещение начала ЛСК тела = перенос тела
// Имя ставим до Update(): иначе в дереве останется системное.
if (Modeling.FeatureName.Normalize(name) is { } title && br is IModelObject named)
named.Name = title;
if (!br.Update())
throw new InvalidOperationException("BodyReposition.Update вернул FALSE.");
doc3d.RebuildDocument();
}, ct);
/// <summary>Объединить все тела активной детали в одно (булева операция «объединение»).</summary>
public Task UnionAllAsync(CancellationToken ct = default)
public Task UnionAllAsync(string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var (doc3d, top) = ActivePart();
@@ -85,6 +91,9 @@ public sealed class SolidEditService
bln.BaseObject = (IKompasAPIObject)bodies[0];
bln.ModifyObjects = bodies.Skip(1).Cast<object>().ToArray();
bln.BooleanType = ksBooleanType.ksUnion;
// Имя ставим до Update(): иначе в дереве останется системное.
if (Modeling.FeatureName.Normalize(name) is { } title && bln is IModelObject named)
named.Name = title;
if (!bln.Update())
throw new InvalidOperationException("Boolean union.Update вернул FALSE.");
doc3d.RebuildDocument();
@@ -0,0 +1,41 @@
namespace Kompas.Mcp.Core.Modeling;
/// <summary>
/// Имя операции (эскиза, формообразующей, скругления…) в дереве построения.
/// <para>КОМПАС по умолчанию именует объекты по типу и номеру («Эскиз:4», «Операция выдавливания:7»),
/// поэтому дерево из полутора десятков операций ничего не говорит ни человеку, ни агенту,
/// вернувшемуся к модели позже. Имя задаётся при создании; здесь — только нормализация и проверка,
/// чтобы она покрывалась тестами без COM.</para>
/// </summary>
public static class FeatureName
{
/// <summary>Предел длины. В справке SDK не документирован — берём заведомо безопасный.</summary>
public const int MaxLength = 100;
/// <summary>
/// Привести имя к виду, пригодному для записи в <c>ksEntity.name</c>: обрезать пробелы по краям,
/// пустое считать «имя не задано» (<c>null</c> — КОМПАС оставит собственное).
/// </summary>
/// <exception cref="ArgumentException">Имя длиннее <see cref="MaxLength"/> или содержит перевод
/// строки/управляющие символы — в дереве такое имя отображается искажённо.</exception>
public static string? Normalize(string? name, string parameterName = "name")
{
if (name is null) return null;
var trimmed = name.Trim();
if (trimmed.Length == 0) return null;
if (trimmed.Length > MaxLength)
throw new ArgumentException(
$"Имя операции длиннее {MaxLength} символов ({trimmed.Length}). " +
"Дайте короткое имя — оно должно читаться в дереве построения.", parameterName);
foreach (var c in trimmed)
{
if (!char.IsControl(c)) continue;
throw new ArgumentException(
"Имя операции не должно содержать перевод строки и управляющие символы.", parameterName);
}
return trimmed;
}
}
+13 -10
View File
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Threading;
using Kompas6API5;
using Kompas6Constants3D;
@@ -27,13 +27,14 @@ public sealed class HoleService
/// (<paramref name="x"/>,<paramref name="y"/>,<paramref name="z"/>), с центром в этой точке.
/// diameter — диаметр (мм). throughAll — сквозное (depth игнорируется), иначе на глубину depth (мм).
/// </summary>
public Task HoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => HoleCore(x, y, z, diameter, depth, throughAll, ksHoleTypeEnum.ksHTBase, null), ct);
public Task HoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll,
string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => HoleCore(x, y, z, diameter, depth, throughAll, ksHoleTypeEnum.ksHTBase, null, name), ct);
/// <summary>Отверстие с цековкой (цилиндрическим уширением сверху, под винт с цилиндрической
/// головкой): основное отверстие diameter + цековка spotDiameter×spotDepth.</summary>
public Task CounterboreHoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll,
double spotDiameter, double spotDepth, CancellationToken ct = default)
double spotDiameter, double spotDepth, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!double.IsFinite(spotDiameter) || spotDiameter <= diameter)
@@ -48,13 +49,13 @@ public sealed class HoleService
?? throw new InvalidOperationException("HoleParameters не приводится к ISpotfacingHoleParameters.");
p.SpotfacingDiameter = spotDiameter;
p.SpotfacingDepth = spotDepth;
});
}, name);
}, ct);
/// <summary>Отверстие с зенковкой (коническим уширением сверху, под винт с потайной головкой):
/// основное отверстие diameter + зенковка sinkDiameter под углом sinkAngle (градусы, конус).</summary>
public Task CountersinkHoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll,
double sinkDiameter, double sinkAngle, CancellationToken ct = default)
double sinkDiameter, double sinkAngle, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!double.IsFinite(sinkDiameter) || sinkDiameter <= diameter)
@@ -68,13 +69,13 @@ public sealed class HoleService
p.CountersinkType = ksCountersinkTypeEnum.ksCTDiameterAngle; // задаём диаметр + угол
p.CountersinkDiameter = sinkDiameter;
p.CountersinkAngle = sinkAngle;
});
}, name);
}, ct);
/// <summary>Коническое отверстие (ksHTConic): конусность задаётся углом conicAngle (градусы).
/// diameter — диаметр у дна, конус расширяется к поверхности.</summary>
public Task ConicHoleAsync(double x, double y, double z, double diameter, double depth, bool throughAll,
double conicAngle, CancellationToken ct = default)
double conicAngle, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!double.IsFinite(conicAngle) || conicAngle <= 0 || conicAngle >= 180)
@@ -85,11 +86,11 @@ public sealed class HoleService
?? throw new InvalidOperationException("HoleParameters не приводится к IConicHoleParameters.");
p.ConicType = ksConicTypeEnum.ksCNAngle;
p.ConicAngle = conicAngle;
});
}, name);
}, ct);
private void HoleCore(double x, double y, double z, double diameter, double depth, bool throughAll,
ksHoleTypeEnum holeType, Action<IHole3D>? configure)
ksHoleTypeEnum holeType, Action<IHole3D>? configure, string? name)
{
if (!double.IsFinite(x) || !double.IsFinite(y) || !double.IsFinite(z))
throw new ArgumentException("Координаты точки отверстия должны быть конечными числами.");
@@ -134,6 +135,8 @@ public sealed class HoleService
hole.EndFaceType = ksEndFaceTypeEnum.ksEFFlat;
hole.Axis = false;
hole.ShowThread = false;
// Имя ставим до Update(): иначе в дереве останется «Отверстие:1».
if (FeatureName.Normalize(name) is { } title && hole is IModelObject named) named.Name = title;
configure?.Invoke(hole); // параметры цековки/зенковки (после установки HoleType)
var disp = (IHoleDisposal)hole;
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas6API5;
using Kompas6Constants3D;
@@ -14,20 +14,27 @@ public sealed partial class PartModeler
/// </summary>
/// <param name="throughAll">Сквозное выдавливание/вырез через всю деталь в обе стороны
/// (для отверстий — гарантированно пробивает материал; <paramref name="depth"/> игнорируется).</param>
/// <param name="thinThickness">Толщина тонкой стенки, мм: контур эскиза трактуется не как граница
/// сплошного сечения, а как средняя/крайняя линия стенки такой толщины. 0 — сплошное тело.</param>
/// <param name="thinSide">Куда откладывать толщину от контура: Outward | Inward | Both.</param>
/// <param name="name">Имя операции в дереве построения («Рельеф букв OldMan»); пусто — имя от КОМПАС.</param>
public Task<int> ExtrudeAsync(
int sketchId, double depth, bool cut = false, bool forward = true,
double draftAngle = 0, bool throughAll = false, CancellationToken ct = default)
double draftAngle = 0, bool throughAll = false,
double thinThickness = 0, ThinSide thinSide = ThinSide.Outward,
string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!throughAll && depth <= 0)
throw new ArgumentOutOfRangeException(nameof(depth), "Глубина должна быть > 0 (или используйте throughAll).");
if (thinThickness < 0)
throw new ArgumentOutOfRangeException(nameof(thinThickness), "Толщина стенки должна быть >= 0.");
var sketch = RequireSketch(sketchId);
CloseSketchCore(sketch); // эскиз должен быть закрыт перед операцией
var part = GetTopPart();
var type = cut ? Obj3dType.o3d_cutExtrusion : Obj3dType.o3d_bossExtrusion;
var entity = part.NewEntity((short)type) as ksEntity
?? throw new InvalidOperationException("NewEntity операции выдавливания вернул null.");
var entity = NewEntity(part, type, name);
// Конкретные типы определений (без dynamic): ошибки связывания — на этапе компиляции.
if (cut)
@@ -45,6 +52,8 @@ public sealed partial class PartModeler
def.directionType = (short)Direction_Type.dtNormal;
def.SetSideParam(forward, (short)End_Type.etBlind, depth, draftAngle, false);
}
var (thin, thinType, normal, reverse) = ThinParams(thinThickness, thinSide);
def.SetThinParam(thin, thinType, normal, reverse);
def.SetSketch(sketch.Entity);
}
else
@@ -55,17 +64,36 @@ public sealed partial class PartModeler
?? throw new InvalidOperationException("GetDefinition() выдавливания вернул не ksBossExtrusionDefinition.");
def.directionType = (short)Direction_Type.dtNormal;
def.SetSideParam(forward, (short)End_Type.etBlind, depth, draftAngle, false);
var (thin, thinType, normal, reverse) = ThinParams(thinThickness, thinSide);
def.SetThinParam(thin, thinType, normal, reverse);
def.SetSketch(sketch.Entity);
}
if (!entity.Create())
throw new InvalidOperationException("Create() операции выдавливания вернул FALSE.");
throw new InvalidOperationException(
"Create() операции выдавливания вернул FALSE." + (thinThickness > 0
? " Тонкая стенка по многоконтурному эскизу (например, по надписи) не строится, " +
"если пересекает уже созданное тело: постройте её ДО этого тела либо соберите " +
"форму через primitive(result=union|subtract)."
: " Эскиз пуст, контур не замкнут или операция не пересекает тело?"));
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Разложить толщину стенки в параметры ksBoss/CutExtrusionDefinition.SetThinParam.</summary>
private static (bool thin, short thinType, double normal, double reverse) ThinParams(double thickness, ThinSide side)
{
if (thickness <= 0) return (false, 0, 0, 0);
// Толщина читается из того поля, которое отвечает выбранному направлению: при dtReverse
// normalThickness игнорируется, и стенка нулевой толщины молча даёт СПЛОШНОЕ сечение.
return side switch
{
ThinSide.Inward => (true, (short)Direction_Type.dtReverse, 0, thickness),
ThinSide.Both => (true, (short)Direction_Type.dtBoth, thickness / 2, thickness / 2),
_ => (true, (short)Direction_Type.dtNormal, thickness, 0),
};
}
/// <summary>
/// Вращать эскиз вокруг его осевой линии (добавьте её через AddAxis). <paramref name="cut"/> —
/// вырезание вместо добавления материала. Угол <paramref name="angle"/> — в градусах.
@@ -73,7 +101,7 @@ public sealed partial class PartModeler
/// </summary>
public Task<int> RevolveAsync(
int sketchId, double angle = 360, bool cut = false, bool forward = true,
CancellationToken ct = default)
string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (angle is <= 0 or > 360)
@@ -83,8 +111,7 @@ public sealed partial class PartModeler
var part = GetTopPart();
var type = cut ? Obj3dType.o3d_cutRotated : Obj3dType.o3d_bossRotated;
var entity = part.NewEntity((short)type) as ksEntity
?? throw new InvalidOperationException("NewEntity операции вращения вернул null.");
var entity = NewEntity(part, type, name);
if (cut)
{
@@ -109,35 +136,39 @@ public sealed partial class PartModeler
if (!entity.Create())
throw new InvalidOperationException("Create() операции вращения вернул FALSE.");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>
/// Скруглить ребро детали, найденное по точке (мировые координаты, мм), радиусом <paramref name="radius"/>.
/// Возвращает id операции.
/// </summary>
public Task<int> FilletEdgeAsync(double x, double y, double z, double radius, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
return CreateFillet(part, SelectEdgeByPoint(part, x, y, z), radius);
}, ct);
public Task<int> FilletEdgeAsync(double x, double y, double z, double radius, string? name = null, CancellationToken ct = default)
=> FilletEdgesByPointsAsync([new Point3d(x, y, z)], radius, name, ct);
/// <summary>Скруглить ребро детали по индексу из list_edges радиусом <paramref name="radius"/>.</summary>
public Task<int> FilletEdgeIndexAsync(int edgeIndex, double radius, CancellationToken ct = default)
=> FilletEdgesAsync([edgeIndex], radius, ct);
public Task<int> FilletEdgeIndexAsync(int edgeIndex, double radius, string? name = null, CancellationToken ct = default)
=> FilletEdgesAsync([edgeIndex], radius, name, ct);
/// <summary>Скруглить одним скруглением несколько рёбер по индексам из list_edges
/// (одна операция в дереве вместо N). Возвращает id операции.</summary>
public Task<int> FilletEdgesAsync(IReadOnlyList<int> edgeIndices, double radius, CancellationToken ct = default)
public Task<int> FilletEdgesAsync(IReadOnlyList<int> edgeIndices, double radius, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
// Диапазон всех индексов проверяем до мутации модели.
var edges = RequireEdges(part, edgeIndices);
return CreateFillet(part, edges, radius);
return CreateFillet(part, edges, radius, name);
}, ct);
/// <summary>Скруглить ОДНОЙ операцией рёбра, найденные по точкам (мировые координаты, мм).
/// Путь для тел, где список рёбер неподъёмен по объёму: N углов — одна операция, а не N.</summary>
public Task<int> FilletEdgesByPointsAsync(IReadOnlyList<Point3d> points, double radius, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var edges = RequireEdgesAt(part, points);
return CreateFillet(part, edges, radius, name);
}, ct);
/// <summary>Разобрать список индексов рёбер в сущности (с проверкой пустоты и диапазона).</summary>
@@ -150,14 +181,37 @@ public sealed partial class PartModeler
return indices.Select(i => SelectEdgeByIndex(part, i)).ToList();
}
private int CreateFillet(ksPart part, ksEntity edge, double radius) => CreateFillet(part, [edge], radius);
/// <summary>Найти рёбра по списку точек. Ненайденные перечисляются ВСЕ сразу: иначе агент
/// выясняет их по одной, теряя вызов на каждую точку.</summary>
private static List<ksEntity> RequireEdgesAt(ksPart part, IReadOnlyList<Point3d> points)
{
ArgumentNullException.ThrowIfNull(points);
if (points.Count == 0)
throw new ArgumentException("Нужна хотя бы одна точка на ребре.", nameof(points));
private int CreateFillet(ksPart part, IReadOnlyList<ksEntity> edges, double radius)
var found = new List<ksEntity>(points.Count);
var missing = new List<string>();
for (var i = 0; i < points.Count; i++)
{
var edge = TrySelectEdgeByPoint(part, points[i].X, points[i].Y, points[i].Z);
if (edge is null) missing.Add(FormattableString.Invariant($"[{i}] ({points[i].X}, {points[i].Y}, {points[i].Z})"));
else found.Add(edge);
}
if (missing.Count > 0)
throw new InvalidOperationException(
"Ребра нет в точках: " + string.Join("; ", missing) +
". Обычная причина — ребро уже съедено соседней операцией либо точка не лежит на ребре. " +
"Повторите вызов без этих точек или уточните координаты по list_edges.");
return found;
}
private int CreateFillet(ksPart part, IReadOnlyList<ksEntity> edges, double radius, string? name)
{
if (radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius), "Радиус скругления должен быть > 0.");
var entity = part.NewEntity((short)Obj3dType.o3d_fillet) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_fillet) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_fillet, name);
var def = entity.GetDefinition() as ksFilletDefinition
?? throw new InvalidOperationException("GetDefinition() скругления вернул не ksFilletDefinition.");
def.radius = radius;
@@ -169,44 +223,44 @@ public sealed partial class PartModeler
if (!entity.Create())
throw new InvalidOperationException("Create() скругления вернул FALSE (радиус слишком велик для ребра?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}
/// <summary>
/// Снять фаску с ребра детали, найденного по точке (мировые координаты, мм), катетом <paramref name="distance"/>.
/// Возвращает id операции.
/// </summary>
public Task<int> ChamferEdgeAsync(double x, double y, double z, double distance, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
return CreateChamfer(part, SelectEdgeByPoint(part, x, y, z), distance);
}, ct);
public Task<int> ChamferEdgeAsync(double x, double y, double z, double distance, string? name = null, CancellationToken ct = default)
=> ChamferEdgesByPointsAsync([new Point3d(x, y, z)], distance, name, ct);
/// <summary>Снять фаску с ребра детали по индексу из list_edges катетом <paramref name="distance"/>.</summary>
public Task<int> ChamferEdgeIndexAsync(int edgeIndex, double distance, CancellationToken ct = default)
=> ChamferEdgesAsync([edgeIndex], distance, ct);
public Task<int> ChamferEdgeIndexAsync(int edgeIndex, double distance, string? name = null, CancellationToken ct = default)
=> ChamferEdgesAsync([edgeIndex], distance, name, ct);
/// <summary>Снять одной операцией фаску с нескольких рёбер по индексам из list_edges.
/// Возвращает id операции.</summary>
public Task<int> ChamferEdgesAsync(IReadOnlyList<int> edgeIndices, double distance, CancellationToken ct = default)
public Task<int> ChamferEdgesAsync(IReadOnlyList<int> edgeIndices, double distance, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var edges = RequireEdges(part, edgeIndices);
return CreateChamfer(part, edges, distance);
return CreateChamfer(part, edges, distance, name);
}, ct);
private int CreateChamfer(ksPart part, ksEntity edge, double distance) => CreateChamfer(part, [edge], distance);
/// <summary>Снять ОДНОЙ операцией фаску с рёбер, найденных по точкам (мировые координаты, мм).</summary>
public Task<int> ChamferEdgesByPointsAsync(IReadOnlyList<Point3d> points, double distance, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var edges = RequireEdgesAt(part, points);
return CreateChamfer(part, edges, distance, name);
}, ct);
private int CreateChamfer(ksPart part, IReadOnlyList<ksEntity> edges, double distance)
private int CreateChamfer(ksPart part, IReadOnlyList<ksEntity> edges, double distance, string? name)
{
if (distance <= 0) throw new ArgumentOutOfRangeException(nameof(distance), "Катет фаски должен быть > 0.");
var entity = part.NewEntity((short)Obj3dType.o3d_chamfer) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_chamfer) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_chamfer, name);
var def = entity.GetDefinition() as ksChamferDefinition
?? throw new InvalidOperationException("GetDefinition() фаски вернул не ksChamferDefinition.");
def.tangent = false;
@@ -218,15 +272,13 @@ public sealed partial class PartModeler
if (!entity.Create())
throw new InvalidOperationException("Create() фаски вернул FALSE (катет слишком велик для ребра?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}
/// <summary>Превратить тело в оболочку: удалить (открыть) грани с индексами faceIndices,
/// задать толщину стенки thickness (мм). outward=false — толщина внутрь (габарит сохраняется),
/// true — наружу. Возвращает id операции.</summary>
public Task<int> ShellAsync(IReadOnlyList<int> faceIndices, double thickness, bool outward = false, CancellationToken ct = default)
public Task<int> ShellAsync(IReadOnlyList<int> faceIndices, double thickness, bool outward = false, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (thickness <= 0) throw new ArgumentOutOfRangeException(nameof(thickness), "Толщина должна быть > 0.");
@@ -238,8 +290,7 @@ public sealed partial class PartModeler
// Проверяем диапазон ВСЕХ индексов до мутации модели.
var faces = indices.Select(i => SelectFaceByIndex(part, i)).ToList();
var entity = part.NewEntity((short)Obj3dType.o3d_shellOperation) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_shellOperation) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_shellOperation, name);
var def = entity.GetDefinition() as ksShellDefinition
?? throw new InvalidOperationException("GetDefinition() оболочки вернул не ksShellDefinition.");
var arr = def.FaceArray() as ksEntityCollection
@@ -252,16 +303,14 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() оболочки вернул FALSE (толщина больше локального радиуса/стенки или несовместимая топология?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Построить ребро жёсткости от разомкнутого контура эскиза до тела.
/// thickness — толщина стенки (мм); side — направление достройки (left|right|up|down);
/// symmetric — толщина симметрична плоскости эскиза; angle — уклон стенок (градусы).
/// Возвращает id операции.</summary>
public Task<int> RibAsync(int sketchId, double thickness, string side = "up", bool symmetric = true, double angle = 0, CancellationToken ct = default)
public Task<int> RibAsync(int sketchId, double thickness, string side = "up", bool symmetric = true, double angle = 0, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
SketchGeometry.RequirePositive(thickness, nameof(thickness)); // ловит и NaN (!(NaN>0))
@@ -271,8 +320,7 @@ public sealed partial class PartModeler
CloseSketchCore(sketch); // эскиз должен быть закрыт перед операцией
var part = GetTopPart();
var entity = part.NewEntity((short)Obj3dType.o3d_ribOperation) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_ribOperation) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_ribOperation, name);
var def = entity.GetDefinition() as ksRibDefinition
?? throw new InvalidOperationException("GetDefinition() ребра вернул не ksRibDefinition.");
def.SetSketch(sketch.Entity);
@@ -288,14 +336,12 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() ребра вернул FALSE (контур не пересекает тело или неверное направление side?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Кинематическая операция (sweep): переместить профиль (эскиз profileSketchId)
/// вдоль траектории (эскиз pathSketchId), образуя сплошное тело. Возвращает id операции.</summary>
public Task<int> SweepAsync(int profileSketchId, int pathSketchId, CancellationToken ct = default)
public Task<int> SweepAsync(int profileSketchId, int pathSketchId, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (profileSketchId == pathSketchId)
@@ -306,8 +352,7 @@ public sealed partial class PartModeler
CloseSketchCore(path);
var part = GetTopPart();
var entity = part.NewEntity((short)Obj3dType.o3d_baseEvolution) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_baseEvolution) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_baseEvolution, name);
var def = entity.GetDefinition() as ksBaseEvolutionDefinition
?? throw new InvalidOperationException("GetDefinition() кинематической операции вернул не ksBaseEvolutionDefinition.");
def.SetSketch(profile.Entity);
@@ -321,14 +366,12 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() кинематической операции вернул FALSE (начало траектории не на плоскости профиля или несовместимая геометрия?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Операция по сечениям (loft): построить сплошное тело по ≥2 закрытым эскизам-сечениям
/// (в порядке списка). closed — замкнуть сечения в кольцо. Возвращает id операции.</summary>
public Task<int> LoftAsync(IReadOnlyList<int> sketchIds, bool closed = false, CancellationToken ct = default)
public Task<int> LoftAsync(IReadOnlyList<int> sketchIds, bool closed = false, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
ArgumentNullException.ThrowIfNull(sketchIds);
@@ -339,8 +382,7 @@ public sealed partial class PartModeler
foreach (var s in sketches) CloseSketchCore(s);
var part = GetTopPart();
var entity = part.NewEntity((short)Obj3dType.o3d_baseLoft) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_baseLoft) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_baseLoft, name);
var def = entity.GetDefinition() as ksBaseLoftDefinition
?? throw new InvalidOperationException("GetDefinition() операции по сечениям вернул не ksBaseLoftDefinition.");
var arr = def.Sketchs() as ksEntityCollection
@@ -353,15 +395,13 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() операции по сечениям вернул FALSE (несовместимые сечения или неверный порядок?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Линейный массив (по сетке вдоль одной координатной оси): размножить операции
/// featureIds вдоль оси axis, count экземпляров всего (включая исходный, ≥2), шаг step (мм)
/// между соседними. geometric=true — быстрое геометрическое копирование. Возвращает id операции.</summary>
public Task<int> LinearPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool geometric = false, CancellationToken ct = default)
public Task<int> LinearPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool geometric = false, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
ArgumentNullException.ThrowIfNull(featureIds);
@@ -377,8 +417,7 @@ public sealed partial class PartModeler
var axisEntity = part.GetDefaultEntity((short)CoordinateAxes.ToObj3dType(axis)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить координатную ось.");
var entity = part.NewEntity((short)Obj3dType.o3d_meshCopy) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_meshCopy) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_meshCopy, name);
var def = entity.GetDefinition() as ksMeshCopyDefinition
?? throw new InvalidOperationException("GetDefinition() массива вернул не ksMeshCopyDefinition.");
def.SetAxis1(axisEntity);
@@ -393,16 +432,14 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() линейного массива вернул FALSE (копии самопересекаются или несовместимая геометрия?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Круговой массив: размножить операции featureIds вокруг координатной оси axis,
/// count экземпляров всего (включая исходный, ≥2), угловой шаг step (градусы) между соседними.
/// reverse — обратное направление вращения. geometric=true — быстрое геометрическое копирование.
/// Возвращает id операции.</summary>
public Task<int> CircularPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool reverse = false, bool geometric = false, CancellationToken ct = default)
public Task<int> CircularPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool reverse = false, bool geometric = false, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
ArgumentNullException.ThrowIfNull(featureIds);
@@ -418,8 +455,7 @@ public sealed partial class PartModeler
var axisEntity = part.GetDefaultEntity((short)CoordinateAxes.ToObj3dType(axis)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить координатную ось.");
var entity = part.NewEntity((short)Obj3dType.o3d_circularCopy) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_circularCopy) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_circularCopy, name);
var def = entity.GetDefinition() as ksCircularCopyDefinition
?? throw new InvalidOperationException("GetDefinition() массива вернул не ksCircularCopyDefinition.");
def.SetAxis(axisEntity);
@@ -441,14 +477,12 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() кругового массива вернул FALSE (копии самопересекаются или неверная ось?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Зеркальная копия операций featureIds относительно координатной плоскости plane.
/// Возвращает id операции.</summary>
public Task<int> MirrorOperationAsync(IReadOnlyList<int> featureIds, BasePlane plane, CancellationToken ct = default)
public Task<int> MirrorOperationAsync(IReadOnlyList<int> featureIds, BasePlane plane, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
ArgumentNullException.ThrowIfNull(featureIds);
@@ -462,8 +496,7 @@ public sealed partial class PartModeler
var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(plane)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить координатную плоскость.");
var entity = part.NewEntity((short)Obj3dType.o3d_mirrorOperation) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_mirrorOperation) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_mirrorOperation, name);
var def = entity.GetDefinition() as ksMirrorCopyDefinition
?? throw new InvalidOperationException("GetDefinition() зеркала вернул не ksMirrorCopyDefinition.");
def.SetPlane(planeEntity);
@@ -475,22 +508,19 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() зеркальной копии вернул FALSE (копия пересекает исходную геометрию?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Зеркально отразить всё тело относительно координатной плоскости plane (получить
/// симметричную деталь: оригинал + зеркало). Возвращает id операции.</summary>
public Task<int> MirrorBodyAsync(BasePlane plane, CancellationToken ct = default)
public Task<int> MirrorBodyAsync(BasePlane plane, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(plane)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить координатную плоскость.");
var entity = part.NewEntity((short)Obj3dType.o3d_mirrorAllOperation) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_mirrorAllOperation) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_mirrorAllOperation, name);
var def = entity.GetDefinition() as ksMirrorCopyAllDefinition
?? throw new InvalidOperationException("GetDefinition() зеркала тела вернул не ksMirrorCopyAllDefinition.");
def.SetPlane(planeEntity);
@@ -502,16 +532,14 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() зеркального отражения тела вернул FALSE (тело пересекает плоскость симметрии?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>Уклон граней: наклонить грани faceIndices на угол angle° относительно нейтральной
/// (опорной) координатной плоскости neutralPlane — сечение тела в этой плоскости остаётся
/// неизменным. outward=true — расширение (материал добавляется), false — сужение. Грани должны
/// граничить/пересекаться с нейтральной плоскостью. Возвращает id операции.</summary>
public Task<int> DraftAsync(IReadOnlyList<int> faceIndices, BasePlane neutralPlane, double angle, bool outward = false, CancellationToken ct = default)
public Task<int> DraftAsync(IReadOnlyList<int> faceIndices, BasePlane neutralPlane, double angle, bool outward = false, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
SketchGeometry.RequireDraftAngle(angle, nameof(angle));
@@ -526,8 +554,7 @@ public sealed partial class PartModeler
var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(neutralPlane)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить координатную плоскость.");
var entity = part.NewEntity((short)Obj3dType.o3d_incline) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_incline) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_incline, name);
var def = entity.GetDefinition() as ksInclineDefinition
?? throw new InvalidOperationException("GetDefinition() уклона вернул не ksInclineDefinition.");
var arr = def.FaceArray() as ksEntityCollection
@@ -541,11 +568,65 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
"Create() уклона вернул FALSE (грань не граничит с нейтральной плоскостью или несовместимая геометрия?).");
var id = _nextId++;
_features[id] = entity;
return id;
return Register(entity);
}, ct);
/// <summary>
/// Удалить операцию или эскиз по id из дерева построения. Нужен для подгонки: неудачная проба
/// (не тот кегль надписи, не та глубина) иначе остаётся в дереве навсегда, и единственным
/// выходом было закрыть документ без сохранения и построить всё заново.
/// <para>КОМПАС удаляет зависимые объекты каскадом: снося эскиз, вы сносите и операции по нему.
/// Возвращает описание удалённого — id больше не действителен.</para>
/// </summary>
public Task<string> DeleteAsync(int id, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var isSketch = _sketches.TryGetValue(id, out var sketch);
if (!isSketch && !_features.ContainsKey(id))
throw new KeyNotFoundException(
$"Объект с id={id} не найден среди эскизов и операций этой сессии. " +
"Удалять можно только созданное текущей сессией (после смены документа id сбрасываются).");
var entity = isSketch ? sketch!.Entity : _features[id];
var what = isSketch ? "Эскиз" : "Операция";
var name = SafeName(entity);
if (isSketch) CloseSketchCore(sketch!); // из режима редактирования объект не удаляется
var doc3d = ActiveDoc3D();
if (!doc3d.DeleteObject(entity))
throw new InvalidOperationException(
$"{what} id={id} не удалена: DeleteObject вернул FALSE. " +
"Объект уже удалён или занят другой операцией?");
var alsoPlane = false;
if (isSketch)
{
// Смещённая плоскость создавалась ради этого эскиза: без неё в дереве остаётся
// «Смещённая плоскость:N», удалить которую агенту нечем — её id наружу не выдаётся.
if (sketch!.OwnedPlane is { } plane)
{
alsoPlane = doc3d.DeleteObject(plane);
ReleaseCom(plane);
}
ReleaseCom(sketch.Definition);
_sketches.Remove(id);
}
else _features.Remove(id);
ReleaseCom(entity);
doc3d.RebuildDocument();
var title = name.Length == 0 ? $"{what} id={id}" : $"{what} id={id} «{name}»";
return alsoPlane ? $"{title} (вместе со смещённой плоскостью)" : title;
}, ct);
/// <summary>Имя объекта для сообщения; у уже нежизнеспособного COM-объекта его может не быть.</summary>
private static string SafeName(ksEntity entity)
{
try { return entity.name ?? ""; }
catch (System.Runtime.InteropServices.COMException) { return ""; }
}
/// <summary>Перестроить документ.</summary>
public Task RebuildAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas6API5;
using Kompas6Constants;
using Kompas6Constants3D;
@@ -10,13 +10,13 @@ namespace Kompas.Mcp.Core.Modeling;
public sealed partial class PartModeler
{
/// <summary>Создать эскиз на базовой плоскости и открыть его на редактирование. Возвращает id эскиза.</summary>
public Task<int> OpenSketchAsync(BasePlane plane, CancellationToken ct = default)
public Task<int> OpenSketchAsync(BasePlane plane, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(plane)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить базовую плоскость.");
return CreateSketchOn(part, planeEntity);
return CreateSketchOn(part, planeEntity, name);
}, ct);
/// <summary>
@@ -24,7 +24,7 @@ public sealed partial class PartModeler
/// (мировые координаты модели, мм), и открыть его на редактирование. Возвращает id эскиза.
/// Это ключ к итеративному циклу «построил → выбрал грань → новый эскиз на ней».
/// </summary>
public Task<int> OpenSketchOnFaceAsync(double x, double y, double z, CancellationToken ct = default)
public Task<int> OpenSketchOnFaceAsync(double x, double y, double z, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
@@ -38,14 +38,14 @@ public sealed partial class PartModeler
var face = faces.GetByIndex(0) as ksEntity
?? throw new InvalidOperationException("Грань из коллекции не приводится к ksEntity.");
return CreateSketchOn(part, face);
return CreateSketchOn(part, face, name);
}, ct);
/// <summary>
/// Создать эскиз на грани детали, заданной индексом из list_faces, и открыть на редактирование.
/// Индекс стабилен, пока геометрия не меняется. Возвращает id эскиза.
/// </summary>
public Task<int> OpenSketchOnFaceIndexAsync(int faceIndex, CancellationToken ct = default)
public Task<int> OpenSketchOnFaceIndexAsync(int faceIndex, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
@@ -57,21 +57,20 @@ public sealed partial class PartModeler
var face = faces.GetByIndex(faceIndex) as ksEntity
?? throw new InvalidOperationException("Грань по индексу не приводится к ksEntity.");
return CreateSketchOn(part, face);
return CreateSketchOn(part, face, name);
}, ct);
/// <summary>
/// Создать смещённую плоскость (база + offset мм) и открыть на ней эскиз. Возвращает id эскиза.
/// Снимает ограничение «только базовые плоскости»: эскиз на произвольной высоте/смещении.
/// </summary>
public Task<int> OpenSketchOnOffsetPlaneAsync(BasePlane basePlane, double offset, bool direction = true, CancellationToken ct = default)
public Task<int> OpenSketchOnOffsetPlaneAsync(BasePlane basePlane, double offset, bool direction = true, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!double.IsFinite(offset))
throw new ArgumentException("Смещение должно быть конечным числом.", nameof(offset));
var part = GetTopPart();
var planeEntity = part.NewEntity((short)Obj3dType.o3d_planeOffset) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_planeOffset) вернул null.");
var planeEntity = NewEntity(part, Obj3dType.o3d_planeOffset, null);
var pdef = planeEntity.GetDefinition() as ksPlaneOffsetDefinition
?? throw new InvalidOperationException("GetDefinition() плоскости вернул не ksPlaneOffsetDefinition.");
var baseEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(basePlane)) as ksEntity
@@ -81,14 +80,14 @@ public sealed partial class PartModeler
pdef.direction = direction;
if (!planeEntity.Create())
throw new InvalidOperationException("Create() смещённой плоскости вернул FALSE.");
return CreateSketchOn(part, planeEntity);
// Плоскость создана ради этого эскиза — она уйдёт из дерева вместе с ним.
return CreateSketchOn(part, planeEntity, name, ownedPlane: planeEntity);
}, ct);
/// <summary>Общее ядро: создать эскиз на заданной плоскости/грани и открыть на редактирование.</summary>
private int CreateSketchOn(ksPart part, ksEntity planeEntity)
private int CreateSketchOn(ksPart part, ksEntity planeEntity, string? name, ksEntity? ownedPlane = null)
{
var entity = part.NewEntity((short)Obj3dType.o3d_sketch) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_sketch) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_sketch, name);
var def = entity.GetDefinition() as ksSketchDefinition
?? throw new InvalidOperationException("GetDefinition() эскиза вернул не ksSketchDefinition.");
@@ -100,7 +99,7 @@ public sealed partial class PartModeler
?? throw new InvalidOperationException("BeginEdit() не вернул ksDocument2D.");
var id = _nextId++;
_sketches[id] = new SketchEntry { Entity = entity, Definition = def, Editor = editor };
_sketches[id] = new SketchEntry { Entity = entity, Definition = def, Editor = editor, OwnedPlane = ownedPlane };
return id;
}
@@ -108,21 +107,23 @@ public sealed partial class PartModeler
/// Добавить пакет примитивов в открытый эскиз за ОДИН заход на STA-поток. Целый контур
/// (десяток отрезков и окружностей) обходится одним COM-сеансом вместо вызова на примитив.
/// <paramref name="autoClose"/> — завершить редактирование эскиза сразу после построения.
/// Возвращает число добавленных примитивов.
/// Возвращает по элементу на примитив: заметку о построенном (пустая строка — сказать нечего,
/// у надписи это её фактическая длина, которую иначе не узнать до выдавливания).
/// </summary>
public Task<int> AddEntitiesAsync(int sketchId, IReadOnlyList<SketchEntity> entities, bool autoClose = false, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
public Task<IReadOnlyList<string>> AddEntitiesAsync(int sketchId, IReadOnlyList<SketchEntity> entities, bool autoClose = false, CancellationToken ct = default)
=> _dispatcher.InvokeAsync<IReadOnlyList<string>>(() =>
{
ArgumentNullException.ThrowIfNull(entities);
var sketch = RequireOpenSketch(sketchId);
var editor = sketch.Editor!;
var notes = new string[entities.Count];
for (int i = 0; i < entities.Count; i++)
{
var e = entities[i] ?? throw new ArgumentException($"Примитив [{i}] пустой.", nameof(entities));
try
{
AddEntityCore(editor, e);
notes[i] = AddEntityCore(editor, e);
}
catch (Exception ex)
{
@@ -133,10 +134,10 @@ public sealed partial class PartModeler
}
if (autoClose) CloseSketchCore(sketch);
return entities.Count;
return notes;
}, ct);
private void AddEntityCore(ksDocument2D editor, SketchEntity e)
private string AddEntityCore(ksDocument2D editor, SketchEntity e)
{
switch (e.Kind)
{
@@ -173,9 +174,12 @@ public sealed partial class PartModeler
case SketchEntityKind.Point:
PointCore(editor, Point(e, 0));
break;
case SketchEntityKind.Text:
return TextCore(editor, Point(e, 0), e).Describe(e.Text);
default:
throw new ArgumentOutOfRangeException(nameof(e), $"Неизвестный тип примитива: {e.Kind}.");
}
return "";
}
/// <summary>Точка пакета по позиции — с внятной ошибкой вместо IndexOutOfRange.</summary>
@@ -374,4 +378,140 @@ public sealed partial class PartModeler
if (editor.ksPoint(p.x, p.y, PointStyle) == 0)
throw new InvalidOperationException("ksPoint вернул 0 (точка не создана).");
}
// Типы динамических массивов API5 (ldefin2d.h) и биты начертания шрифта.
private const int TextLineArrType = 3; // TEXT_LINE_ARR — строки надписи
private const int TextItemArrType = 4; // TEXT_ITEM_ARR — компоненты строки
private const int FontItalicOn = 0x40; // ITALIC_ON
private const int FontBoldOn = 0x100; // BOLD_ON
/// <summary>Габарит объекта эскиза по его reference или <c>null</c>, если КОМПАС его не отдал.
/// Отсутствие габарита не повод рушить построение — примитив уже создан.</summary>
private (double MinX, double MinY, double MaxX, double MaxY)? GabaritOf(ksDocument2D editor, int reference)
{
ksRectParam? rect = null;
try
{
rect = NewParam<ksRectParam>(StructType2DEnum.ko_RectParam);
if (editor.ksGetObjGabaritRect(reference, rect) == 0) return null;
if (rect.GetpBot() is not ksMathPointParam bot) return null;
if (rect.GetpTop() is not ksMathPointParam top) return null;
try { return (bot.x, bot.y, top.x, top.y); }
finally { ReleaseCom(bot); ReleaseCom(top); }
}
catch (System.Runtime.InteropServices.COMException) { return null; }
finally { ReleaseCom(rect); }
}
/// <summary>
/// Надпись в эскизе. Текст выбранным шрифтом сразу переводится в кривые
/// (<c>ksConvertTextToCurve</c>): пока он остаётся текстом, это оформление, и формообразующая
/// операция его не видит. Возвращает метрики надписи — по ним агент выравнивает её, иначе
/// фактическое место, занятое буквами, известно только после выдавливания.
/// <para>Выравнивание КОМПАС не поддерживает (аргумент <c>align</c> у <c>ksTextEx</c> строку не
/// двигает), поэтому при <c>align</c>/<c>vAlign</c> отличных от умолчания надпись строится,
/// замеряется и СДВИГАЕТСЯ (<c>ksMoveObj</c>) — те же три шага, которые иначе тратил бы агент.
/// Пересоздать её вместо сдвига нельзя: <c>ksDeleteObj</c> по ссылке от
/// <c>ksConvertTextToCurve</c> возвращает успех, но кривые в эскизе остаются.</para>
/// </summary>
private TextMetrics TextCore(ksDocument2D editor, (double x, double y) at, SketchEntity e)
{
var (metrics, reference) = InsertTextCore(editor, at, e);
if (e.Align == TextAlign.Left && e.VAlign == TextVAlign.Baseline) return metrics;
if (metrics.Box is not { } box) return metrics; // без габарита выравнивать нечем
var (dx, dy) = TextAligns.Offset(box, at, e.Align, e.VAlign);
if (Math.Abs(dx) < 1e-9 && Math.Abs(dy) < 1e-9) return metrics;
if (editor.ksMoveObj(reference, dx, dy) == 0)
throw new InvalidOperationException(
"Надпись не удалось выровнять: ksMoveObj отверг кривые. " +
"Постройте её с align=left и сместите точку вставки сами.");
// Габарит сдвигаем арифметикой: перечитать его по ссылке нельзя — она указывает на
// текстовый объект, а его рамка идёт по ячейке строки, а не по глифам.
return metrics with { Box = (box.MinX + dx, box.MinY + dy, box.MaxX + dx, box.MaxY + dy) };
}
/// <summary>Одна вставка надписи: метрики и ссылка на объект (нужна для перестановки).</summary>
private (TextMetrics Metrics, int Reference) InsertTextCore(ksDocument2D editor, (double x, double y) at, SketchEntity e)
{
var text = e.Text ?? "";
if (text.Length == 0)
throw new ArgumentException("Пустая надпись: задайте text.");
SketchGeometry.RequirePositive(e.Height, "height");
SketchGeometry.RequirePositive(e.WidthFactor, "widthFactor");
ksTextParam? param = null;
ksTextItemParam? item = null;
ksTextLineParam? line = null;
ksDynamicArray? items = null;
ksDynamicArray? lines = null;
try
{
param = NewParam<ksTextParam>(StructType2DEnum.ko_TextParam);
param.Init();
if (param.GetParagraphParam() is not ksParagraphParam paragraph)
throw new InvalidOperationException("GetParagraphParam() вернул не ksParagraphParam.");
paragraph.Init();
paragraph.x = at.x;
paragraph.y = at.y;
paragraph.ang = e.Angle;
paragraph.height = e.Height;
paragraph.width = 0;
param.SetParagraphParam(paragraph);
item = NewParam<ksTextItemParam>(StructType2DEnum.ko_TextItemParam);
item.Init();
item.type = (int)ksTextItemEnum.ksTItString;
item.s = text;
if (item.GetItemFont() is not ksTextItemFont font)
throw new InvalidOperationException("GetItemFont() вернул не ksTextItemFont.");
font.Init();
if (!string.IsNullOrWhiteSpace(e.FontName)) font.fontName = e.FontName;
font.height = e.Height;
font.ksu = e.WidthFactor;
font.bitVector = (e.Bold ? FontBoldOn : 0) | (e.Italic ? FontItalicOn : 0);
item.SetItemFont(font);
items = _session.Kompas.GetDynamicArray(TextItemArrType) as ksDynamicArray
?? throw new InvalidOperationException("GetDynamicArray(TEXT_ITEM_ARR) вернул null.");
items.ksAddArrayItem(-1, item);
line = NewParam<ksTextLineParam>(StructType2DEnum.ko_TextLineParam);
line.Init();
line.style = MainLineStyle;
line.SetTextItemArr(items);
lines = _session.Kompas.GetDynamicArray(TextLineArrType) as ksDynamicArray
?? throw new InvalidOperationException("GetDynamicArray(TEXT_LINE_ARR) вернул null.");
lines.ksAddArrayItem(-1, line);
param.SetTextLineArr(lines);
var reference = editor.ksTextEx(param, 0);
if (reference == 0)
throw new InvalidOperationException(
$"ksTextEx вернул 0 (надпись не создана — шрифт «{e.FontName}» установлен в системе?).");
var advance = editor.ksGetTextLengthFromReference(reference);
var curves = editor.ksConvertTextToCurve(reference);
if (curves == 0)
throw new InvalidOperationException("ksConvertTextToCurve вернул 0 (надпись не переведена в кривые).");
// Габарит берём по ссылке на КРИВЫЕ, которую вернула конвертация: у исходного текстового
// объекта рамка идёт по ячейке строки, а она шире глифов (на скриптовых шрифтах — до 8 %).
// Ссылка на ТЕКСТ, а не на кривые: правки положения принимает именно она.
return (new TextMetrics(advance, GabaritOf(editor, curves)), reference);
}
finally
{
// Динамические массивы живут в КОМПАС, а не в CLR: без ksDeleteArray они останутся в памяти.
lines?.ksDeleteArray();
items?.ksDeleteArray();
ReleaseCom(lines);
ReleaseCom(items);
ReleaseCom(line);
ReleaseCom(item);
ReleaseCom(param);
}
}
}
+33 -6
View File
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Threading;
using Kompas6API5;
@@ -38,6 +38,11 @@ public sealed partial class PartModeler : IDisposable
public required ksEntity Entity { get; init; }
public required ksSketchDefinition Definition { get; init; }
public ksDocument2D? Editor { get; set; } // не null, пока эскиз открыт на редактирование
/// <summary>Смещённая плоскость, созданная РАДИ этого эскиза (у эскизов на базовой
/// плоскости или грани — null). Удаляется вместе с ним: иначе в дереве остаётся
/// «Смещённая плоскость:N», которую агенту нечем убрать — её id наружу не выдаётся.</summary>
public ksEntity? OwnedPlane { get; init; }
}
/// <summary>
@@ -67,15 +72,14 @@ public sealed partial class PartModeler : IDisposable
=> ActiveDoc3D().GetPart((short)Part_Type.pTop_Part) as ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
/// <summary>Выбрать ребро детали по точке (мировые координаты). Берёт первое подходящее.</summary>
private static ksEntity SelectEdgeByPoint(ksPart part, double x, double y, double z)
/// <summary>Выбрать ребро детали по точке (мировые координаты). Берёт первое подходящее;
/// отсутствие ребра — не ошибка: пакетный выбор перечисляет все промахи разом.</summary>
private static ksEntity? TrySelectEdgeByPoint(ksPart part, double x, double y, double z)
{
var edges = part.EntityCollection((short)Obj3dType.o3d_edge) as ksEntityCollection
?? throw new InvalidOperationException("Не удалось получить коллекцию рёбер.");
edges.SelectByPoint(x, y, z);
if (edges.GetCount() == 0)
throw new InvalidOperationException(
$"В точке ({x}, {y}, {z}) не найдено ребра. Сделайте снимок и сверьте координаты точки на ребре.");
if (edges.GetCount() == 0) return null;
return edges.GetByIndex(0) as ksEntity
?? throw new InvalidOperationException("Ребро из коллекции не приводится к ksEntity.");
}
@@ -104,6 +108,28 @@ public sealed partial class PartModeler : IDisposable
?? throw new InvalidOperationException("Ребро по индексу не приводится к ksEntity.");
}
/// <summary>
/// Создать сущность детали и сразу дать ей имя в дереве построения. Имя присваивается ДО
/// <c>Create()</c>: КОМПАС иначе показывает безликое «Операция выдавливания:3», и агент,
/// вернувшийся к дереву через сотню вызовов, не может сказать, что именно строила операция.
/// </summary>
private static ksEntity NewEntity(ksPart part, Obj3dType type, string? name)
{
var entity = part.NewEntity((short)type) as ksEntity
?? throw new InvalidOperationException($"NewEntity({type}) вернул null.");
var normalized = FeatureName.Normalize(name);
if (normalized is not null) entity.name = normalized;
return entity;
}
/// <summary>Зарегистрировать созданную операцию под новым id (ссылки из массивов/зеркала).</summary>
private int Register(ksEntity entity)
{
var id = _nextId++;
_features[id] = entity;
return id;
}
private SketchEntry RequireSketch(int id)
=> _sketches.TryGetValue(id, out var s) ? s
: throw new KeyNotFoundException($"Эскиз с id={id} не найден.");
@@ -136,6 +162,7 @@ public sealed partial class PartModeler : IDisposable
ReleaseCom(s.Editor);
ReleaseCom(s.Definition);
ReleaseCom(s.Entity);
ReleaseCom(s.OwnedPlane);
}
foreach (var f in _features.Values)
ReleaseCom(f);
+7
View File
@@ -0,0 +1,7 @@
namespace Kompas.Mcp.Core.Modeling;
/// <summary>Точка в мировых координатах модели (мм) — для выбора рёбер/граней пакетом.</summary>
/// <param name="X">Координата X, мм.</param>
/// <param name="Y">Координата Y, мм.</param>
/// <param name="Z">Координата Z, мм.</param>
public readonly record struct Point3d(double X, double Y, double Z);
@@ -0,0 +1,44 @@
using System.ComponentModel;
using System.Text.Json.Serialization;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>
/// Точка на объекте модели во входе инструмента (мировые координаты, мм). Нужна там, где объекты
/// выбираются пакетом: список рёбер под одно скругление — это одна операция в дереве вместо N.
/// </summary>
public sealed class Point3dInput
{
[JsonPropertyName("x")]
[Description("X точки, мм")]
public double X { get; set; }
[JsonPropertyName("y")]
[Description("Y точки, мм")]
public double Y { get; set; }
[JsonPropertyName("z")]
[Description("Z точки, мм")]
public double Z { get; set; }
}
/// <summary>Разбор списка точек из входа инструмента.</summary>
public static class Point3dInputs
{
/// <summary>Преобразовать вход в точки модели, проверив конечность координат.</summary>
public static IReadOnlyList<Point3d> Parse(IReadOnlyList<Point3dInput>? points, string parameterName = "points")
{
if (points is null || points.Count == 0) return [];
var parsed = new List<Point3d>(points.Count);
for (var i = 0; i < points.Count; i++)
{
var p = points[i] ?? throw new ArgumentException($"Точка [{i}] пустая.", parameterName);
if (!double.IsFinite(p.X) || !double.IsFinite(p.Y) || !double.IsFinite(p.Z))
throw new ArgumentException($"Точка [{i}]: координаты должны быть конечными числами.", parameterName);
parsed.Add(new Point3d(p.X, p.Y, p.Z));
}
return parsed;
}
}
@@ -0,0 +1,103 @@
using Kompas6Constants3D;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>Элементарное тело («примитив»): строится без эскиза, по размерам.</summary>
public enum PrimitiveKind
{
Block,
Cylinder,
Sphere,
Cone,
}
/// <summary>Как результат примитива сочетается с уже построенными телами.</summary>
public enum PrimitiveResult
{
/// <summary>Отдельное новое тело (объединять потом вручную).</summary>
NewBody,
/// <summary>Объединить с телом, которое примитив пересекает.</summary>
Union,
/// <summary>Вычесть из тела — карман/паз без эскиза и выреза.</summary>
Subtract,
/// <summary>Оставить только пересечение.</summary>
Intersect,
}
public static class Primitives
{
public static PrimitiveKind ParseKind(string? value) => (value ?? "").Trim().ToLowerInvariant() switch
{
"block" or "box" => PrimitiveKind.Block,
"cylinder" => PrimitiveKind.Cylinder,
"sphere" => PrimitiveKind.Sphere,
"cone" => PrimitiveKind.Cone,
_ => throw new ArgumentException(
$"Неизвестный примитив '{value}'. Допустимо: block | cylinder | sphere | cone.", nameof(value)),
};
public static PrimitiveResult ParseResult(string? value) => (value ?? "").Trim().ToLowerInvariant() switch
{
"" or "new" or "newbody" or "new_body" => PrimitiveResult.NewBody,
"union" or "add" => PrimitiveResult.Union,
"subtract" or "cut" => PrimitiveResult.Subtract,
"intersect" => PrimitiveResult.Intersect,
_ => throw new ArgumentException(
$"Неизвестный режим '{value}'. Допустимо: new | union | subtract | intersect.", nameof(value)),
};
public static ksOperationResultEnum ToOperationResult(PrimitiveResult result) => result switch
{
PrimitiveResult.Union => ksOperationResultEnum.ksOperationUnion,
PrimitiveResult.Subtract => ksOperationResultEnum.ksOperationCut,
PrimitiveResult.Intersect => ksOperationResultEnum.ksOperationIntersect,
_ => ksOperationResultEnum.ksOperationNewBody,
};
/// <summary>Тип объекта для <c>IElementaryBodies.Add</c> — именно ksObj3dTypeEnum, не Obj3dType
/// (значения совпадают, но API7 принимает только этот enum).</summary>
public static ksObj3dTypeEnum ToObj3dType(PrimitiveKind kind) => kind switch
{
PrimitiveKind.Block => ksObj3dTypeEnum.o3d_BlockBySizes,
PrimitiveKind.Cylinder => ksObj3dTypeEnum.o3d_Cylinder,
PrimitiveKind.Sphere => ksObj3dTypeEnum.o3d_Sphere,
PrimitiveKind.Cone => ksObj3dTypeEnum.o3d_ConeByHeight,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Неизвестный примитив."),
};
/// <summary>
/// Проверить набор размеров под конкретный примитив. Схема инструмента не может запретить
/// неверную комбинацию полей, поэтому ошибка обязана называть kind и недостающий параметр.
/// </summary>
public static void Validate(PrimitiveKind kind, double? length, double? width, double? height, double? diameter)
{
switch (kind)
{
case PrimitiveKind.Block:
Positive(length, "length", kind);
Positive(width, "width", kind);
Positive(height, "height", kind);
break;
case PrimitiveKind.Cylinder:
case PrimitiveKind.Cone:
Positive(diameter, "diameter", kind);
Positive(height, "height", kind);
break;
case PrimitiveKind.Sphere:
Positive(diameter, "diameter", kind);
break;
}
}
private static void Positive(double? value, string name, PrimitiveKind kind)
{
if (value is null)
throw new ArgumentException($"kind={kind.ToString().ToLowerInvariant()} требует параметр {name}.");
if (!double.IsFinite(value.Value) || value.Value <= 0)
throw new ArgumentException(
$"kind={kind.ToString().ToLowerInvariant()}: {name} должен быть конечным числом > 0 (передано {value}).");
}
}
@@ -0,0 +1,177 @@
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Threading;
using Kompas6Constants3D;
using KompasAPI7;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>
/// Элементарные тела API7 (<c>IElementaryBodies</c>): параллелепипед, цилиндр, сфера, конус.
/// Это второй способ формообразования рядом с «эскиз → выдавливание»: тело задаётся размерами,
/// без эскиза, и сразу может объединяться/вычитаться из существующего
/// (<see cref="PrimitiveResult"/> → <c>ksOperationResultEnum</c>).
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class PrimitiveService
{
private readonly KompasSession _session;
private readonly KompasDispatcher _dispatcher;
public PrimitiveService(KompasSession session, KompasDispatcher dispatcher)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
}
/// <summary>
/// Построить примитив в точке (<paramref name="x"/>,<paramref name="y"/>,<paramref name="z"/>) —
/// для блока это угол, для остальных центр основания. Размеры — по осям текущей системы координат:
/// length по X, width по Y, height по Z.
/// </summary>
public Task PrimitiveAsync(
PrimitiveKind kind, double x, double y, double z,
double? length = null, double? width = null, double? height = null, double? diameter = null,
PrimitiveResult result = PrimitiveResult.NewBody, bool forward = true,
string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
Primitives.Validate(kind, length, width, height, diameter);
if (!double.IsFinite(x) || !double.IsFinite(y) || !double.IsFinite(z))
throw new ArgumentException("Координаты точки привязки должны быть конечными числами.");
if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d)
throw new InvalidOperationException("Активный документ не является 3D-документом.");
if (doc3d.TopPart is not IPart7 top)
throw new InvalidOperationException("Не удалось получить TopPart активного документа.");
if (top is not IModelContainer container)
throw new InvalidOperationException("Деталь не приводится к IModelContainer.");
var bodies = container.ElementaryBodies
?? throw new InvalidOperationException("ElementaryBodies недоступны (нужен КОМПАС v21 и новее).");
var before = CurrentVolume();
var body = bodies.Add(Primitives.ToObj3dType(kind))
?? throw new InvalidOperationException($"ElementaryBodies.Add({kind}) вернул null.");
try
{
var elementary = (IElementaryBody)body;
elementary.OperationResult = Primitives.ToOperationResult(result);
// Имя ставим до Update(): иначе в дереве останется «Параллелепипед:2».
if (FeatureName.Normalize(name) is { } title && elementary is IModelObject named)
named.Name = title;
Place(elementary, x, y, z);
Configure(body, kind, length, width, height, diameter, forward);
if (!elementary.Update())
throw new InvalidOperationException(
$"Update() примитива вернул FALSE: {Explain(result)}");
RequireVolumeChanged(before, result);
}
catch
{
// Неудачный примитив не должен оставаться в дереве «сиротой».
try { ((IElementaryBody)body).Owner?.Delete(); } catch { /* лучшее усилие */ }
throw;
}
finally { ComHelper.Release(body); ComHelper.Release(bodies); }
}, ct);
/// <summary>Точка привязки примитива задаётся его локальной системой координат.</summary>
private static void Place(IElementaryBody body, double x, double y, double z)
{
var position = body.Position
?? throw new InvalidOperationException("Position примитива недоступна.");
position.X = x;
position.Y = y;
position.Z = z;
}
private static void Configure(
object body, PrimitiveKind kind,
double? length, double? width, double? height, double? diameter, bool forward)
{
var direction = forward ? ksDirectionTypeEnum.dtNormal : ksDirectionTypeEnum.dtReverse;
// Height/HeightType/Diameter у примитивов — ИНДЕКСИРОВАННЫЕ свойства: индекс [true] это
// прямая сторона, [false] — обратная (для двунаправленных построений). Без индекса не компилируется.
const bool normalSide = true;
switch (kind)
{
case PrimitiveKind.Block:
var block = (IBlockBySizes)body;
block.BlockPosition = ksBlockPositionTypeEnum.ksVertex; // точка привязки — угол
block.Length = length!.Value;
block.Width = width!.Value;
block.Height[normalSide] = height!.Value;
block.LengthDirection = true;
block.WidthDirection = true;
block.HeightDirection = direction;
block.HeightType[normalSide] = ksEndTypeEnum.etBlind;
break;
case PrimitiveKind.Cylinder:
var cylinder = (ICylinder)body;
cylinder.Diameter = diameter!.Value;
cylinder.Height[normalSide] = height!.Value;
cylinder.HeightDirection = direction;
cylinder.HeightType[normalSide] = ksEndTypeEnum.etBlind;
break;
case PrimitiveKind.Sphere:
((ISphere)body).Diameter = diameter!.Value;
break;
case PrimitiveKind.Cone:
var cone = (IConeByHeight)body;
cone.BaseDiameter = diameter!.Value;
cone.Diameter[normalSide] = 0; // вершина сведена в точку — полный конус
cone.Height[normalSide] = height!.Value;
cone.ConeDirection = direction;
cone.HeightType[normalSide] = ksEndTypeEnum.etBlind;
break;
}
}
/// <summary>
/// Update()==TRUE ещё не значит, что примитив что-то изменил: вычитание мимо тела КОМПАС
/// считает успехом и молча оставляет модель как была. Проверяем по объёму.
/// </summary>
private void RequireVolumeChanged(double before, PrimitiveResult result)
{
const double eps = 1e-6;
var after = CurrentVolume();
switch (result)
{
case PrimitiveResult.Subtract when after > before - eps:
throw new InvalidOperationException(
"Вычитание примитива не удалило материал: тело не пересекается с ним. " +
"Сверьте точку привязки и размеры (для блока x,y,z — это УГОЛ, а не центр).");
case PrimitiveResult.Intersect when Math.Abs(after - before) < eps:
throw new InvalidOperationException(
"Пересечение с примитивом не изменило тело — примитив накрывает его целиком или не задевает.");
case PrimitiveResult.NewBody or PrimitiveResult.Union when after < before + eps:
throw new InvalidOperationException(
"Примитив не добавил материала: нулевые размеры или он целиком внутри существующего тела.");
}
}
/// <summary>Текущий объём детали (мм³) через API5 МЦХ — та же метрика, что у QueryService.</summary>
private double CurrentVolume()
{
const int mixMmKg = 0x1 | 0x10; // ST_MIX_MM | ST_MIX_KG
var doc3d = _session.Kompas.ActiveDocument3D() as Kompas6API5.ksDocument3D
?? throw new InvalidOperationException("Нет активного 3D-документа (API5).");
var part = doc3d.GetPart((short)Kompas6Constants3D.Part_Type.pTop_Part) as Kompas6API5.ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
var mass = part.CalcMassInertiaProperties(mixMmKg) as Kompas6API5.ksMassInertiaParam;
return mass?.v ?? 0;
}
/// <summary>Update() у примитива возвращает FALSE молча — подсказываем вероятную причину.</summary>
private static string Explain(PrimitiveResult result) => result switch
{
PrimitiveResult.Subtract => "вычитание не пересекает ни одно тело или удалило бы его целиком?",
PrimitiveResult.Union => "объединять не с чем — примитив не касается существующих тел?",
PrimitiveResult.Intersect => "пересечение пустое?",
_ => "нулевые или несовместимые размеры?",
};
}
@@ -14,6 +14,7 @@ public enum SketchEntityKind
Spline,
Point,
Axis,
Text,
}
/// <summary>
@@ -41,4 +42,28 @@ public sealed record SketchEntity
public bool Closed { get; init; }
public bool CounterClockwise { get; init; } = true;
public bool Inscribed { get; init; } = true;
// ---- text ---------------------------------------------------------------
/// <summary>Содержимое надписи (Kind=Text). Строится шрифтом <see cref="FontName"/>
/// и сразу переводится в кривые — иначе текст в эскизе не участвует в выдавливании.</summary>
public string? Text { get; init; }
/// <summary>Имя установленного в системе шрифта. Пусто — текущий шрифт документа (GOST type A).</summary>
public string? FontName { get; init; }
/// <summary>Высота шрифта, мм (Kind=Text).</summary>
public double Height { get; init; }
/// <summary>Коэффициент сужения: 1 — нормальная ширина, &lt;1 — уже, &gt;1 — шире.</summary>
public double WidthFactor { get; init; } = 1.0;
public bool Bold { get; init; }
public bool Italic { get; init; }
/// <summary>Чем считать точку вставки по горизонтали: левым краем (умолчание), центром, правым краем.</summary>
public TextAlign Align { get; init; } = TextAlign.Left;
/// <summary>Чем считать точку вставки по вертикали: базовой линией (умолчание), низом, серединой, верхом глифов.</summary>
public TextVAlign VAlign { get; init; } = TextVAlign.Baseline;
}
@@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
using System.Text.Json.Serialization;
namespace Kompas.Mcp.Core.Modeling;
@@ -10,12 +10,13 @@ namespace Kompas.Mcp.Core.Modeling;
public sealed class SketchEntityInput
{
[JsonPropertyName("type")]
[Description("Тип примитива: line | circle | rectangle | arc | arc3points | ellipse | polyline | polygon | spline | point | axis")]
[Description("Тип примитива: line | circle | rectangle | arc | arc3points | ellipse | polyline | polygon | spline | point | axis | text")]
public string Type { get; set; } = "";
[JsonPropertyName("points")]
[Description("Точки в плоскости эскиза (мм). line/axis — 2 точки; rectangle — 2 противоположных угла; " +
"arc3points — 3 (начало, промежуточная на дуге, конец); polyline/spline — N точек; point — 1")]
"arc3points — 3 (начало, промежуточная на дуге, конец); polyline/spline — N точек; " +
"point — 1; text — 1 (левый край базовой линии надписи)")]
public SketchPoint[]? Points { get; set; }
[JsonPropertyName("centerX")]
@@ -47,9 +48,49 @@ public sealed class SketchEntityInput
public double? SemiMinor { get; set; }
[JsonPropertyName("angle")]
[Description("Угол поворота, градусы: наклон большой оси (ellipse) или поворот первой вершины (polygon). По умолчанию 0")]
[Description("Угол поворота, градусы: наклон большой оси (ellipse), поворот первой вершины (polygon) " +
"или наклон строки (text). По умолчанию 0")]
public double? Angle { get; set; }
[JsonPropertyName("text")]
[Description("Содержимое надписи — для text. Сразу переводится в кривые, поэтому надпись можно выдавливать")]
public string? Text { get; set; }
[JsonPropertyName("fontName")]
[Description("Имя шрифта, установленного в системе (например «GOST type A», «Arial», «Lobster») — для text. " +
"По умолчанию текущий шрифт документа")]
public string? FontName { get; set; }
[JsonPropertyName("height")]
[Description("Высота шрифта, мм — для text. Это высота ПРОПИСНОЙ (cap height): восходящие " +
"и выносные элементы выходят за неё. Ширина строки линейна по height")]
public double? Height { get; set; }
[JsonPropertyName("widthFactor")]
[Description("Коэффициент ширины шрифта — для text: 1 обычный, <1 уже, >1 шире. Меняет пропорцию " +
"начертания крупными шагами (1.0→1.25 даёт +29 % ширины), но КОМПАС квантует его " +
"ступенями: соседние значения дают одинаковую ширину, а переход через ступень — " +
"скачок ~2 %. Точную ширину набирайте height. По умолчанию 1")]
public double? WidthFactor { get; set; }
[JsonPropertyName("bold")]
[Description("Полужирное начертание — для text. По умолчанию false")]
public bool? Bold { get; set; }
[JsonPropertyName("italic")]
[Description("Наклонное начертание — для text. По умолчанию false")]
public bool? Italic { get; set; }
[JsonPropertyName("align")]
[Description("Чем считать точку вставки по горизонтали — для text: left (по умолчанию) | center | right. " +
"center избавляет от цикла «вставил → замерил → переставил»")]
public string? Align { get; set; }
[JsonPropertyName("vAlign")]
[Description("Чем считать точку вставки по вертикали — для text: baseline (по умолчанию, выносные уходят " +
"ниже) | bottom | middle | top — по фактическому габариту глифов")]
public string? VAlign { get; set; }
[JsonPropertyName("vertexCount")]
[Description("Число вершин (>=3) — для polygon")]
public int? VertexCount { get; set; }
@@ -74,7 +115,7 @@ public sealed class SketchEntityInput
/// </summary>
public static class SketchEntityInputs
{
private const string KnownTypes = "line | circle | rectangle | arc | arc3points | ellipse | polyline | polygon | spline | point | axis";
private const string KnownTypes = "line | circle | rectangle | arc | arc3points | ellipse | polyline | polygon | spline | point | axis | text";
public static IReadOnlyList<SketchEntity> Parse(IReadOnlyList<SketchEntityInput>? entities)
{
@@ -140,6 +181,12 @@ public static class SketchEntityInputs
Require(e.Radius, "radius", kind);
Require(e.VertexCount, "vertexCount", kind);
break;
case SketchEntityKind.Text:
RequirePoints(points, 1, kind, "точку вставки (левый край базовой линии)");
if (string.IsNullOrEmpty(e.Text))
throw new ArgumentException("type=text требует параметр text (непустую строку).");
Require(e.Height, "height", kind);
break;
}
return new SketchEntity
@@ -158,6 +205,14 @@ public static class SketchEntityInputs
Closed = e.Closed ?? false,
CounterClockwise = e.CounterClockwise ?? true,
Inscribed = e.Inscribed ?? true,
Text = e.Text,
FontName = e.FontName,
Height = e.Height ?? 0,
WidthFactor = e.WidthFactor ?? 1.0,
Bold = e.Bold ?? false,
Italic = e.Italic ?? false,
Align = TextAligns.Parse(e.Align),
VAlign = TextAligns.ParseVertical(e.VAlign),
};
}
@@ -174,6 +229,7 @@ public static class SketchEntityInputs
"spline" => SketchEntityKind.Spline,
"point" => SketchEntityKind.Point,
"axis" => SketchEntityKind.Axis,
"text" => SketchEntityKind.Text,
_ => throw new ArgumentException($"неизвестный тип примитива '{type}'. Допустимо: {KnownTypes}."),
};
+80
View File
@@ -0,0 +1,80 @@
namespace Kompas.Mcp.Core.Modeling;
/// <summary>Чем считать точку вставки надписи по горизонтали.</summary>
public enum TextAlign
{
/// <summary>Левый край глифов (как кладёт сам КОМПАС).</summary>
Left,
/// <summary>Середина строки — точка задаёт центр.</summary>
Center,
/// <summary>Правый край глифов.</summary>
Right,
}
/// <summary>Чем считать точку вставки надписи по вертикали.</summary>
public enum TextVAlign
{
/// <summary>Базовая линия шрифта (как кладёт сам КОМПАС): выносные элементы уходят ниже.</summary>
Baseline,
/// <summary>Нижняя граница глифов (с учётом выносных).</summary>
Bottom,
/// <summary>Середина глифов по высоте.</summary>
Middle,
/// <summary>Верхняя граница глифов.</summary>
Top,
}
/// <summary>
/// Разбор выравнивания надписи. КОМПАС умеет класть строку только вправо-вверх от точки вставки
/// (аргумент <c>align</c> у <c>ksTextEx</c> строку не двигает), поэтому центрирование — это
/// «вставить, замерить, переставить». Раньше эти три шага тратил агент, теперь их делает сервер.
/// </summary>
public static class TextAligns
{
public static TextAlign Parse(string? value, string parameterName = "align")
=> (value ?? "").Trim().ToLowerInvariant() switch
{
"" or "left" => TextAlign.Left,
"center" or "centre" or "middle" => TextAlign.Center,
"right" => TextAlign.Right,
_ => throw new ArgumentException(
$"Неизвестное выравнивание «{value}». Допустимо: left | center | right.", parameterName),
};
public static TextVAlign ParseVertical(string? value, string parameterName = "vAlign")
=> (value ?? "").Trim().ToLowerInvariant() switch
{
"" or "baseline" => TextVAlign.Baseline,
"bottom" => TextVAlign.Bottom,
"middle" or "center" or "centre" => TextVAlign.Middle,
"top" => TextVAlign.Top,
_ => throw new ArgumentException(
$"Неизвестное вертикальное выравнивание «{value}». Допустимо: baseline | bottom | middle | top.",
parameterName),
};
/// <summary>
/// Насколько сдвинуть надпись, чтобы точка вставки стала выбранным якорем.
/// <paramref name="box"/> — фактический габарит глифов после первой вставки.
/// </summary>
public static (double Dx, double Dy) Offset(
(double MinX, double MinY, double MaxX, double MaxY) box,
(double X, double Y) anchor,
TextAlign align, TextVAlign vAlign)
{
var dx = align switch
{
TextAlign.Center => anchor.X - (box.MinX + box.MaxX) / 2,
TextAlign.Right => anchor.X - box.MaxX,
_ => 0,
};
var dy = vAlign switch
{
TextVAlign.Bottom => anchor.Y - box.MinY,
TextVAlign.Middle => anchor.Y - (box.MinY + box.MaxY) / 2,
TextVAlign.Top => anchor.Y - box.MaxY,
_ => 0,
};
return (dx, dy);
}
}
@@ -0,0 +1,38 @@
using System.Globalization;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>
/// Метрики построенной надписи. Агенту нужны ДВЕ разные величины, и путать их дорого:
/// <list type="bullet">
/// <item><description><b>Габарит глифов</b> — прямоугольник, реально занятый буквами. По нему
/// выравнивают надпись и вписывают её в поле; только он совпадает с тем, что получится после
/// выдавливания.</description></item>
/// <item><description><b>Ширина ячейки</b> (<c>ksGetTextLengthFromReference</c>) — шаг строки
/// с боковыми выносами. Больше габарита на 1–2 % у наборных шрифтов и на 7–9 % у скриптовых.</description></item>
/// </list>
/// Пока в ответе была только ячейка, подгонка надписи под размер шла вслепую: вставить →
/// выдавить → замерить → пересобрать документ.
/// </summary>
/// <param name="Advance">Ширина ячейки строки, мм.</param>
/// <param name="Box">Габарит глифов в координатах эскиза (мм) или <c>null</c>, если КОМПАС его не отдал.</param>
public readonly record struct TextMetrics(
double Advance,
(double MinX, double MinY, double MaxX, double MaxY)? Box)
{
/// <summary>Заметка для ответа инструмента: где легли буквы и сколько заняли.</summary>
public string Describe(string? text)
{
var what = string.IsNullOrEmpty(text) ? "надпись" : $"надпись «{text}»";
var cell = F(Advance);
if (Box is not { } b)
return $"{what} — ширина ячейки {cell} мм (габарит глифов КОМПАС не отдал)";
var width = F(b.MaxX - b.MinX);
var height = F(b.MaxY - b.MinY);
return $"{what} — глифы X {F(b.MinX)}…{F(b.MaxX)}, Y {F(b.MinY)}…{F(b.MaxY)} " +
$"({width} × {height} мм), ширина ячейки {cell} мм";
}
private static string F(double value) => value.ToString("F2", CultureInfo.InvariantCulture);
}
+26
View File
@@ -0,0 +1,26 @@
namespace Kompas.Mcp.Core.Modeling;
/// <summary>Куда откладывается толщина тонкой стенки от контура эскиза.</summary>
public enum ThinSide
{
/// <summary>Наружу от контура (контур — внутренняя граница стенки).</summary>
Outward,
/// <summary>Внутрь контура (контур — наружная граница стенки).</summary>
Inward,
/// <summary>Симметрично в обе стороны (контур — средняя линия стенки).</summary>
Both,
}
public static class ThinSides
{
public static ThinSide Parse(string? value) => (value ?? "").Trim().ToLowerInvariant() switch
{
"" or "outward" or "out" => ThinSide.Outward,
"inward" or "in" => ThinSide.Inward,
"both" or "middle" => ThinSide.Both,
_ => throw new ArgumentException(
$"Неизвестная сторона стенки '{value}'. Допустимо: outward | inward | both.", nameof(value)),
};
}
+51
View File
@@ -11,4 +11,55 @@ public sealed record EdgeInfo
/// <summary>Длина ребра, мм.</summary>
public required double Length { get; init; }
/// <summary>Концевая вершина (начало), мировые координаты, мм. У замкнутых кривых совпадает с <see cref="End"/>.</summary>
public (double X, double Y, double Z)? Start { get; init; }
/// <summary>Концевая вершина (конец), мировые координаты, мм.</summary>
public (double X, double Y, double Z)? End { get; init; }
}
/// <summary>
/// Отбор рёбер для пакетной операции. Дело не в удобстве: на теле с надписью рёбер сотни, полный
/// список неподъёмен по объёму ответа, а без него нечем адресовать «весь нижний контур» —
/// фаска против «слоновьей ноги» упиралась именно в это.
/// </summary>
public sealed record EdgeFilter
{
public string? Type { get; init; }
public double? MinX { get; init; }
public double? MaxX { get; init; }
public double? MinY { get; init; }
public double? MaxY { get; init; }
public double? MinZ { get; init; }
public double? MaxZ { get; init; }
/// <summary>Задан ли хоть один критерий (иначе перечисляем всё).</summary>
public bool IsEmpty => Type is null && MinX is null && MaxX is null
&& MinY is null && MaxY is null && MinZ is null && MaxZ is null;
/// <summary>
/// Проходит ли ребро отбор. Координатное окно проверяется по ОБОИМ концам: у прямой и дуги это
/// точный признак «ребро лежит в окне», у замкнутой кривой концы совпадают — тогда признак
/// говорит лишь о том, что кривая проходит через окно.
/// </summary>
public bool Matches(EdgeInfo edge)
{
ArgumentNullException.ThrowIfNull(edge);
if (Type is { } type && !string.Equals(edge.Type, type.Trim(), StringComparison.OrdinalIgnoreCase))
return false;
if (IsEmptyBox) return true;
if (edge.Start is not { } s || edge.End is not { } e) return false; // без координат окно не проверить
return Inside(s) && Inside(e);
}
private bool IsEmptyBox => MinX is null && MaxX is null && MinY is null
&& MaxY is null && MinZ is null && MaxZ is null;
private bool Inside((double X, double Y, double Z) p)
=> (MinX is null || p.X >= MinX) && (MaxX is null || p.X <= MaxX)
&& (MinY is null || p.Y >= MinY) && (MaxY is null || p.Y <= MaxY)
&& (MinZ is null || p.Z >= MinZ) && (MaxZ is null || p.Z <= MaxZ);
}
+18 -1
View File
@@ -97,11 +97,28 @@ public static class InspectionText
.Select(x => $"{x.Count} {x.Key}"));
}
/// <summary>
/// Наименование и обозначение детали в заголовке паспорта. Пустое наименование — это «Деталь»
/// в дереве: подсказываем, чем его задать, пока модель не ушла в чертёж или спецификацию.
/// </summary>
public static string PartIdentityText(ModelDescription d)
{
var name = (d.PartName ?? "").Trim();
var marking = (d.PartMarking ?? "").Trim();
if (name.Length == 0 && marking.Length == 0)
return " — деталь без наименования (задайте через set_part_info)";
var parts = new List<string>(2);
if (name.Length > 0) parts.Add($"наименование «{name}»");
if (marking.Length > 0) parts.Add($"обозначение «{marking}»");
return " — " + string.Join(", ", parts);
}
/// <summary>Отрендерить «паспорт» модели в читаемый текст (по умолчанию — все разделы).</summary>
public static string Render(ModelDescription d, ModelSections sections = ModelSections.All)
{
var sb = new StringBuilder();
sb.AppendLine($"Документ: «{d.Name}» ({d.DocumentTypeName})");
sb.AppendLine($"Документ: «{d.Name}» ({d.DocumentTypeName}){PartIdentityText(d)}");
if (d.IsEmpty)
{
@@ -9,6 +9,12 @@ public sealed record ModelDescription
/// <summary>Имя документа/детали.</summary>
public required string Name { get; init; }
/// <summary>Наименование детали (имя вершинного компонента; в дереве вместо «Деталь»).</summary>
public string PartName { get; init; } = "";
/// <summary>Обозначение детали по ЕСКД.</summary>
public string PartMarking { get; init; } = "";
/// <summary>Модель пуста (нет тел; габарит-«пустышка»).</summary>
public required bool IsEmpty { get; init; }
@@ -40,6 +40,8 @@ public sealed class ModelInspectionService
try
{
var (name, typeName) = ActiveDocInfo();
var partName = ComHelper.SafeStr(() => part.name);
var partMarking = ComHelper.SafeStr(() => part.marking);
bool hasGab = part.GetGabarit(false, false,
out double x1, out double y1, out double z1, out double x2, out double y2, out double z2);
@@ -49,7 +51,7 @@ public sealed class ModelInspectionService
if (empty)
return new ModelDescription
{
Name = name, IsEmpty = true,
Name = name, PartName = partName, PartMarking = partMarking, IsEmpty = true,
Bodies = Array.Empty<BodyInfo>(), FaceTypes = Array.Empty<string>(),
EdgeTypes = Array.Empty<string>(), Features = Array.Empty<FeatureInfo>(),
Imported = false, Variables = Array.Empty<VariableInfo>(),
@@ -73,6 +75,8 @@ public sealed class ModelInspectionService
return new ModelDescription
{
Name = name,
PartName = partName,
PartMarking = partMarking,
IsEmpty = false,
Box = sections.HasFlag(InspectionText.ModelSections.Box) ? box : null,
Mass = sections.HasFlag(InspectionText.ModelSections.Mass) ? ReadMass(part) : null,
+23 -4
View File
@@ -135,8 +135,12 @@ public sealed class QueryService
finally { ComHelper.Release(part); }
}, ct);
/// <summary>Перечислить рёбра активной детали: индекс, тип кривой и длину (мм).</summary>
public Task<IReadOnlyList<EdgeInfo>> ListEdgesAsync(CancellationToken ct = default)
/// <summary>
/// Перечислить рёбра активной детали: индекс, тип кривой, длину (мм) и концевые вершины.
/// <paramref name="filter"/> отбирает подмножество — на теле с надписью рёбер сотни, и без
/// отбора «весь нижний контур» адресовать нечем.
/// </summary>
public Task<IReadOnlyList<EdgeInfo>> ListEdgesAsync(EdgeFilter? filter = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync<IReadOnlyList<EdgeInfo>>(() =>
{
var part = GetTopPart();
@@ -151,18 +155,33 @@ public sealed class QueryService
{
if (edges.GetByIndex(i) is not ksEntity entity) continue;
if (entity.GetDefinition() is not ksEdgeDefinition def) continue;
list.Add(new EdgeInfo
var info = new EdgeInfo
{
Index = i,
Type = GeomClassifiers.EdgeType(def),
Length = def.GetLength(MixMm),
});
Start = VertexPoint(def, start: true),
End = VertexPoint(def, start: false),
};
if (filter is null || filter.Matches(info)) list.Add(info);
}
return list;
}
finally { ComHelper.Release(part); }
}, ct);
/// <summary>Координаты концевой вершины ребра или <c>null</c> (у части кривых вершин нет).</summary>
private static (double X, double Y, double Z)? VertexPoint(ksEdgeDefinition def, bool start)
{
try
{
if (def.GetVertex(start) is ksVertexDefinition v && v.GetPoint(out double x, out double y, out double z))
return (x, y, z);
}
catch (System.Runtime.InteropServices.COMException) { /* нет вершины — не повод рушить перечисление */ }
return null;
}
/// <summary>Габаритный параллелепипед активной детали (по телам), мм.</summary>
public Task<BoundingBox> GetBoundingBoxAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
@@ -0,0 +1,36 @@
namespace Kompas.Mcp.Core.Startup;
/// <summary>
/// Текст ошибки инструмента для клиента. MCP-SDK показывает сообщение исключения только у
/// <c>McpException</c>, всё остальное подменяет на «An error occurred invoking '&lt;tool&gt;'» —
/// а для агента ценна как раз причина: какого параметра не хватило и что делать дальше.
/// Форматирование живёт в Core, чтобы покрываться unit-тестами без MCP-хоста.
/// </summary>
public static class ToolErrorText
{
/// <summary>Собрать сообщение: имя инструмента, причина и вложенные причины (без стека).</summary>
public static string Describe(string? toolName, Exception exception)
{
ArgumentNullException.ThrowIfNull(exception);
var reasons = new List<string>(3);
for (var e = Unwrap(exception); e is not null && reasons.Count < 3; e = e.InnerException)
{
var message = (e.Message ?? "").Trim();
// Служебный текст обёрток («One or more errors occurred») агенту ничего не говорит.
if (message.Length == 0 || e is AggregateException) continue;
if (!reasons.Contains(message)) reasons.Add(message);
}
if (reasons.Count == 0) reasons.Add(exception.GetType().Name);
var where = string.IsNullOrWhiteSpace(toolName) ? "Инструмент" : $"Инструмент «{toolName}»";
return $"{where} не выполнен: {string.Join(" ", reasons)}";
}
/// <summary>Развернуть агрегаты диспетчера: настоящая причина лежит внутри.</summary>
private static Exception Unwrap(Exception exception)
=> exception is AggregateException aggregate && aggregate.InnerExceptions.Count == 1
? Unwrap(aggregate.InnerExceptions[0])
: exception;
}
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Threading;
using Kompas6API5;
@@ -63,8 +63,9 @@ public sealed class SnapshotService
// вывод работает надёжно.
param.returnResultAsArrayBytes = false;
var outFile = saveToFile ?? Path.Combine(
Path.GetTempPath(), $"kompas-snap-{Guid.NewGuid():N}.{format.FileExtension()}");
var outFile = saveToFile is null
? Path.Combine(Path.GetTempPath(), $"kompas-snap-{Guid.NewGuid():N}.{format.FileExtension()}")
: Documents.OutputPath.Prepare(saveToFile, nameof(saveToFile));
var deleteAfter = saveToFile is null;
try
+41
View File
@@ -1,10 +1,13 @@
using System.Diagnostics;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Assemblies;
using Kompas.Mcp.Core.Conversion;
using Kompas.Mcp.Core.Diagnostics;
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Drawings;
using Kompas.Mcp.Core.Editing;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Host;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
using Kompas.Mcp.Core.Startup;
@@ -14,6 +17,7 @@ using Kompas.Mcp.Core.Vision;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
// Interop-сборки КОМПАС в поставку сервера не входят (принадлежат АСКОН) — они берутся из
// установленного КОМПАС-3D. Резолвер вешается до любых обращений к COM-типам; сам поиск ленивый,
@@ -40,6 +44,7 @@ builder.Services.AddSingleton<KompasSession>();
builder.Services.AddSingleton<DocumentService>();
builder.Services.AddSingleton<PartModeler>();
builder.Services.AddSingleton<HoleService>();
builder.Services.AddSingleton<PrimitiveService>();
builder.Services.AddSingleton<AssemblyService>();
builder.Services.AddSingleton<DrawingService>();
builder.Services.AddSingleton<VariableService>();
@@ -53,10 +58,39 @@ builder.Services.AddSingleton<ValidationService>();
// Режим авто-валидации живёт на весь процесс: умолчание из KOMPAS_MCP_AUTOVALIDATE, переключение — set_auto_validate.
builder.Services.AddSingleton<AutoValidation>();
// Журнал операций создаётся до хоста: его же захватывает фильтр вызовов ниже, а инструмент
// set_operation_log получает тот же экземпляр через DI.
var operationLog = new OperationLog();
builder.Services.AddSingleton(operationLog);
// MCP-сервер поверх stdio; инструменты находятся по атрибутам в этой сборке.
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
// Без фильтра клиент получает на любую ошибку безликое «An error occurred invoking '<tool>'»:
// SDK показывает текст исключения, только если оно McpException. А вся польза наших сообщений
// именно в тексте («какого параметра не хватает», «почему операция не построилась»).
// Здесь же снимается журнал операций: единственная точка, через которую проходит каждый вызов.
.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (context, ct) =>
{
var started = Stopwatch.GetTimestamp();
try
{
var result = await next(context, ct);
operationLog.Write(ToolCallLog.Describe(context, Stopwatch.GetElapsedTime(started), result, error: null));
return result;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
var message = ToolErrorText.Describe(context.Params?.Name, ex);
operationLog.Write(ToolCallLog.Describe(context, Stopwatch.GetElapsedTime(started), result: null, message));
return new CallToolResult
{
Content = [new TextContentBlock { Text = message }],
IsError = true,
};
}
}))
.WithToolsFromAssembly();
var app = builder.Build();
@@ -74,4 +108,11 @@ catch (KompasInteropException ex)
interopLog.LogWarning("{Message}", ex.Message);
}
// Состояние журнала операций — в stderr на старте: иначе включённый через окружение журнал
// работает молча, а невключившийся (плохой путь) молчит вдвойне.
if (operationLog.Enabled || operationLog.LastError is not null)
{
interopLog.LogInformation("{Message}", operationLog.Describe());
}
await app.RunAsync();
+65
View File
@@ -0,0 +1,65 @@
using Kompas.Mcp.Core.Diagnostics;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Kompas.Mcp.Host;
/// <summary>
/// Перевод вызова инструмента из терминов MCP в запись журнала. Живёт в хосте, потому что
/// <c>Kompas.Mcp.Core</c> о протоколе не знает (и не должен: его тесты гоняются без MCP-хоста).
/// </summary>
internal static class ToolCallLog
{
/// <summary>Собрать запись о завершившемся вызове.</summary>
/// <param name="context">Контекст запроса: имя инструмента, аргументы, сессия, клиент.</param>
/// <param name="duration">Сколько занял вызов.</param>
/// <param name="result">Ответ инструмента (<c>null</c>, если вылетело исключение).</param>
/// <param name="error">Текст ошибки, ушедший агенту, или <c>null</c> при успехе.</param>
public static OperationLogEntry Describe(
RequestContext<CallToolRequestParams> context,
TimeSpan duration,
CallToolResult? result,
string? error)
{
var failed = error is not null || result?.IsError == true;
var text = ResultText(result);
return new OperationLogEntry
{
Timestamp = DateTimeOffset.UtcNow,
Tool = context.Params?.Name ?? "?",
Arguments = context.Params?.Arguments as IReadOnlyDictionary<string, System.Text.Json.JsonElement>
?? context.Params?.Arguments?.ToDictionary(a => a.Key, a => a.Value),
Session = context.Server?.SessionId,
Client = ClientName(context.Server?.ClientInfo),
Duration = duration,
Ok = !failed,
// Ошибка инструмента приходит обычным ответом с IsError — её текст ценнее, чем поле result.
Error = error ?? (failed ? text : null),
Result = failed ? null : text,
};
}
private static string? ClientName(Implementation? client)
=> client is null
? null
: string.IsNullOrEmpty(client.Version) ? client.Name : $"{client.Name} {client.Version}";
/// <summary>
/// Текст ответа. Снимок модели (base64 в <c>ImageContentBlock</c>) в журнал не попадает
/// никогда — вместо картинки пишется её размер.
/// </summary>
private static string? ResultText(CallToolResult? result)
{
if (result?.Content is not { Count: > 0 } content) return null;
var parts = content.Select(block => block switch
{
TextContentBlock text => text.Text,
ImageContentBlock image => $"<изображение {image.MimeType}, {image.Data.Length} байт>",
_ => $"<{block.Type}>",
});
return string.Join("\n", parts);
}
}
+33 -4
View File
@@ -47,20 +47,49 @@ public sealed class DocumentTools(KompasSession session, DocumentService docs, P
return "Документ сохранён.";
}
await docs.SaveAsAsync(path);
return $"Сохранено: {path}";
var saved = await docs.SaveAsAsync(path);
return $"Сохранено: {saved}";
}
[McpServerTool(Name = "document_close")]
[Description("Закрыть активный документ. save — сохранять ли изменения при закрытии.")]
public async Task<string> Close([Description("Сохранять изменения")] bool save = false)
[Description("Закрыть активный документ. save — сохранять ли изменения при закрытии. " +
"all=true закрывает ВСЕ открытые документы БЕЗ сохранения — этим освобождают имя файла, " +
"занятое другим открытым документом (иначе КОМПАС молча не перезапишет его).")]
public async Task<string> Close(
[Description("Сохранять изменения")] bool save = false,
[Description("Закрыть все открытые документы без сохранения")] bool all = false)
{
await session.ConnectAsync();
if (all)
{
var closed = await docs.CloseAllAsync();
await modeler.ResetAsync();
return $"Закрыто документов: {closed} (без сохранения).";
}
var ok = await docs.CloseAsync(save);
await modeler.ResetAsync();
return ok ? "Документ закрыт." : "Не удалось закрыть документ.";
}
[McpServerTool(Name = "set_part_info")]
[Description("Задать наименование и/или обозначение детали (сборки) активного 3D-документа. " +
"Наименование заменяет безликую «Деталь» в дереве построения и подставляется в штамп " +
"чертежа и в спецификацию, поэтому давайте его КАЖДОЙ детали, а не только перед выдачей. " +
"Не переданный параметр остаётся без изменений. Возвращает значения после записи.")]
public async Task<string> SetPartInfo(
[Description("Наименование детали, например «Значок декоративный»")] string? name = null,
[Description("Обозначение по ЕСКД, например «ОМЕ.741124.001»")] string? marking = null)
{
await session.ConnectAsync();
if (name is null && marking is null)
throw new ArgumentException("Передайте name и/или marking — иначе менять нечего.");
var identity = await docs.SetPartIdentityAsync(name, marking);
var markingPart = identity.Marking.Length == 0 ? "" : $", обозначение «{identity.Marking}»";
return $"Деталь: наименование «{identity.Name}»{markingPart}.";
}
[McpServerTool(Name = "document_active")]
[Description("Сведения об активном документе.")]
public async Task<string> Active()
+13 -9
View File
@@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Editing;
using Kompas.Mcp.Core.Modeling;
@@ -28,7 +28,8 @@ public sealed class EditTools(
[Description("Индекс грани из list_faces")] int? faceIndex = null,
[Description("X точки на грани, мм")] double? x = null,
[Description("Y точки на грани, мм")] double? y = null,
[Description("Z точки на грани, мм")] double? z = null)
[Description("Z точки на грани, мм")] double? z = null,
[Description("Имя операции в дереве построения, например «Наращивание торца»")] string? name = null)
{
await session.ConnectAsync();
@@ -50,7 +51,7 @@ public sealed class EditTools(
"Не задана грань: передайте faceIndex из list_faces (надёжнее) либо все три координаты точки на грани (x, y, z).");
}
await faceEdit.MoveFaceAsync(px, py, pz, distance);
await faceEdit.MoveFaceAsync(px, py, pz, distance, name);
return await autoValidation.AnnotateAsync($"{where} перемещена на {distance} мм вдоль нормали.");
}
@@ -61,10 +62,11 @@ public sealed class EditTools(
public async Task<string> SplitSolidByPlane(
[Description("Базовая плоскость: XOY | XOZ | YOZ")] string plane,
[Description("Смещение плоскости от базовой, мм")] double offset,
[Description("Прямое направление отступа")] bool direction = true)
[Description("Прямое направление отступа")] bool direction = true,
[Description("Имя операции в дереве построения, например «Рез под вставку 5 мм»")] string? name = null)
{
await session.ConnectAsync();
var bodies = await solidEdit.SplitByPlaneAsync(BasePlanes.Parse(plane), offset, direction);
var bodies = await solidEdit.SplitByPlaneAsync(BasePlanes.Parse(plane), offset, direction, name);
var lines = bodies.Select(b =>
$" [{b.Index}] габ {b.SizeX:F1}×{b.SizeY:F1}×{b.SizeZ:F1} мм, " +
$"Z[{b.MinZ:F1}; {b.MaxZ:F1}]");
@@ -74,10 +76,11 @@ public sealed class EditTools(
[McpServerTool(Name = "move_body")]
[Description("Переместить тело (по индексу из split_solid_by_plane) на вектор (dx, dy, dz) мм. " +
"Шаг 2 метода «рассечь→раздвинуть→объединить» — раздвигает части на нужный зазор.")]
public async Task<string> MoveBody(int bodyIndex, double dx = 0, double dy = 0, double dz = 0)
public async Task<string> MoveBody(int bodyIndex, double dx = 0, double dy = 0, double dz = 0,
[Description("Имя операции в дереве построения, например «Раздвижка верхней части»")] string? name = null)
{
await session.ConnectAsync();
await solidEdit.MoveBodyAsync(bodyIndex, dx, dy, dz);
await solidEdit.MoveBodyAsync(bodyIndex, dx, dy, dz, name);
return await autoValidation.AnnotateAsync($"Тело [{bodyIndex}] перемещено на ({dx}, {dy}, {dz}) мм.");
}
@@ -85,10 +88,11 @@ public sealed class EditTools(
[Description("Объединить все тела активной детали в одно (булева операция «объединение»). " +
"Шаг 4 (финал) метода «рассечь→раздвинуть→объединить»: после стыковки граней (move_face) " +
"сливает части в единое тело.")]
public async Task<string> BooleanUnion()
public async Task<string> BooleanUnion(
[Description("Имя операции в дереве построения, например «Сшивка частей корпуса»")] string? name = null)
{
await session.ConnectAsync();
await solidEdit.UnionAllAsync();
await solidEdit.UnionAllAsync(name);
return await autoValidation.AnnotateAsync("Тела объединены.");
}
}
+134 -43
View File
@@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
@@ -13,27 +13,74 @@ public sealed class FeatureTools(
KompasSession session,
PartModeler modeler,
HoleService holes,
PrimitiveService primitives,
ModelInspectionService inspection,
AutoValidation autoValidation)
{
[McpServerTool(Name = "primitive")]
[Description("Построить элементарное тело БЕЗ эскиза, по размерам: kind=block (length×width×height " +
"по осям X/Y/Z, точка x,y,z — угол) | cylinder (diameter, height) | sphere (diameter) | " +
"cone (diameter основания, height); у тел вращения x,y,z — центр основания. " +
"result задаёт, что делать с уже построенным: new (отдельное тело) | union (приплюсовать) | " +
"subtract (вычесть — карман или паз без эскиза и выреза) | intersect. Для призматических " +
"форм это короче связки sketch_create+extrude; сложный контур (текст, кривые) по-прежнему " +
"делается эскизом.")]
public async Task<string> Primitive(
[Description("Тип тела: block | cylinder | sphere | cone")] string kind,
[Description("X точки привязки, мм")] double x = 0,
[Description("Y точки привязки, мм")] double y = 0,
[Description("Z точки привязки, мм")] double z = 0,
[Description("Длина по X, мм — для block")] double? length = null,
[Description("Ширина по Y, мм — для block")] double? width = null,
[Description("Высота по Z, мм — для block | cylinder | cone")] double? height = null,
[Description("Диаметр, мм — для cylinder | sphere | cone (основания)")] double? diameter = null,
[Description("Что сделать с существующим телом: new | union | subtract | intersect")] string result = "new",
[Description("Прямое направление высоты (иначе вниз от точки привязки)")] bool forward = true,
[Description("Имя операции в дереве построения, например «Плашка 82×10.5»")] string? name = null)
{
await session.ConnectAsync();
var parsedKind = Primitives.ParseKind(kind);
var parsedResult = Primitives.ParseResult(result);
await primitives.PrimitiveAsync(parsedKind, x, y, z, length, width, height, diameter, parsedResult, forward, name);
var what = parsedKind.ToString().ToLowerInvariant();
var how = parsedResult switch
{
PrimitiveResult.Union => "объединён с телом",
PrimitiveResult.Subtract => "вычтен из тела",
PrimitiveResult.Intersect => "оставлено пересечение",
_ => "создан отдельным телом",
};
return await autoValidation.AnnotateAsync($"Примитив {what} в ({x}, {y}, {z}) — {how}.");
}
[McpServerTool(Name = "extrude")]
[Description("Выдавить эскиз: mode=boss — добавить материал, mode=cut — вырезать. depth — глубина в мм. " +
"Для сквозного выреза передайте throughAll=true (depth не нужен; для boss сквозной режим " +
"неприменим). forward — прямое направление; если операция ушла «не туда», повторите с " +
"forward=false. Возвращает id операции.")]
"forward=false. thinThickness>0 превращает контур в стенку такой толщины (тонкостенное тело); " +
"thinSide=outward наращивает её наружу — так из контура получается ободок/эквидистанта вокруг " +
"него. Возвращает id операции.")]
public async Task<string> Extrude(
[Description("Id эскиза (должен быть закрыт или закроется автоматически)")] int sketchId,
[Description("Режим: boss (добавить материал) | cut (вырезать)")] string mode,
[Description("Глубина, мм (не нужна при throughAll)")] double depth = 0,
[Description("Вырезать насквозь через всю деталь (только для cut)")] bool throughAll = false,
[Description("Прямое направление")] bool forward = true)
[Description("Прямое направление")] bool forward = true,
[Description("Толщина тонкой стенки, мм; 0 — сплошное сечение по контуру")] double thinThickness = 0,
[Description("Сторона стенки от контура: outward | inward | both")] string thinSide = "outward",
[Description("Имя операции в дереве построения, например «Рельеф букв OldMan»")] string? name = null)
{
await session.ConnectAsync();
var cut = ParseCutMode(mode);
var id = await modeler.ExtrudeAsync(sketchId, depth, cut: cut, forward: forward, throughAll: throughAll);
var side = ThinSides.Parse(thinSide);
var id = await modeler.ExtrudeAsync(sketchId, depth, cut: cut, forward: forward, throughAll: throughAll,
thinThickness: thinThickness, thinSide: side, name: name);
var what = cut ? "Вырез" : "Выдавливание";
var how = throughAll ? "насквозь" : $"на {depth} мм";
return await autoValidation.AnnotateAsync($"{what} выдавливанием {how} создан(о), id={id}.");
var wall = thinThickness > 0 ? $", стенка {thinThickness} мм ({side.ToString().ToLowerInvariant()})" : "";
return await autoValidation.AnnotateAsync($"{what} выдавливанием {how}{wall} создан(о), id={id}.");
}
[McpServerTool(Name = "revolve")]
@@ -44,62 +91,85 @@ public sealed class FeatureTools(
[Description("Id эскиза с профилем и осевой линией")] int sketchId,
[Description("Режим: boss (добавить материал) | cut (вырезать)")] string mode,
[Description("Угол вращения, градусы (0..360]")] double angle = 360,
[Description("Прямое направление")] bool forward = true)
[Description("Прямое направление")] bool forward = true,
[Description("Имя операции в дереве построения, например «Тело вращения корпуса»")] string? name = null)
{
await session.ConnectAsync();
var cut = ParseCutMode(mode);
var id = await modeler.RevolveAsync(sketchId, angle, cut: cut, forward: forward);
var id = await modeler.RevolveAsync(sketchId, angle, cut: cut, forward: forward, name: name);
var what = cut ? "Вырез вращением" : "Вращение";
return await autoValidation.AnnotateAsync($"{what} на {angle}° создан(о), id={id}.");
}
[McpServerTool(Name = "fillet_edge")]
[Description("Скруглить рёбра детали радиусом radius (мм) — одной операцией. Рёбра задаются ЛИБО списком " +
"индексов edgeIndices из list_edges (предпочтительно: не нужно угадывать координаты, за один " +
"вызов скругляется хоть весь контур), ЛИБО точкой x,y,z на ребре в мировых координатах модели " +
"(запасной вариант — тогда скругляется одно ребро). Возвращает id операции.")]
[Description("Скруглить рёбра детали радиусом radius (мм) — ОДНОЙ операцией. Рёбра задаются любым из " +
"способов: edgeIndices — список индексов из list_edges (предпочтительно, когда список рёбер " +
"обозрим); points — список точек {x,y,z} на рёбрах (для тел с сотнями рёбер, где list_edges " +
"неподъёмен: все углы скругляются одной операцией, а не N вызовами); x,y,z — одна точка. " +
"Если ребра нет в части точек, ошибка перечислит ВСЕ такие точки сразу. Возвращает id операции.")]
public async Task<string> FilletEdge(
[Description("Радиус скругления, мм")] double radius,
[Description("Индексы рёбер из list_edges")] int[]? edgeIndices = null,
[Description("Точки на рёбрах: [{x,y,z}, …] одна операция на все")] Point3dInput[]? points = null,
[Description("X точки на ребре, мм")] double? x = null,
[Description("Y точки на ребре, мм")] double? y = null,
[Description("Z точки на ребре, мм")] double? z = null)
[Description("Z точки на ребре, мм")] double? z = null,
[Description("Имя операции в дереве построения, например «Скругления углов плашки»")] string? name = null)
{
await session.ConnectAsync();
if (edgeIndices is { Length: > 0 })
{
var id = await modeler.FilletEdgesAsync(edgeIndices, radius);
var id = await modeler.FilletEdgesAsync(edgeIndices, radius, name);
var count = edgeIndices.Distinct().Count();
return await autoValidation.AnnotateAsync($"Скругление R{radius} создано на {count} рёбрах, id={id}.");
}
var (px, py, pz) = RequirePoint(x, y, z, "ребре", "edgeIndices");
var byPoint = await modeler.FilletEdgeAsync(px, py, pz, radius);
var parsed = Point3dInputs.Parse(points);
if (parsed.Count > 0)
{
var id = await modeler.FilletEdgesByPointsAsync(parsed, radius, name);
return await autoValidation.AnnotateAsync(
$"Скругление R{radius} создано на {parsed.Count} рёбрах (по точкам), id={id}.");
}
var (px, py, pz) = RequirePoint(x, y, z, "ребре", "edgeIndices/points");
var byPoint = await modeler.FilletEdgeAsync(px, py, pz, radius, name);
return await autoValidation.AnnotateAsync(
$"Скругление R{radius} создано на ребре в точке ({px}, {py}, {pz}), id={byPoint}.");
}
[McpServerTool(Name = "chamfer_edge")]
[Description("Снять фаску с рёбер детали катетом distance (мм) — одной операцией. Рёбра задаются ЛИБО " +
"списком индексов edgeIndices из list_edges (предпочтительно), ЛИБО точкой x,y,z на ребре " +
"в мировых координатах модели (тогда обрабатывается одно ребро). Возвращает id операции.")]
[Description("Снять фаску с рёбер детали катетом distance (мм) — ОДНОЙ операцией. Рёбра задаются любым " +
"из способов: edgeIndices — список индексов из list_edges; points — список точек {x,y,z} " +
"на рёбрах (одна операция на все, путь для тел с сотнями рёбер); x,y,z — одна точка. " +
"Возвращает id операции.")]
public async Task<string> ChamferEdge(
[Description("Катет фаски, мм")] double distance,
[Description("Индексы рёбер из list_edges")] int[]? edgeIndices = null,
[Description("Точки на рёбрах: [{x,y,z}, …] одна операция на все")] Point3dInput[]? points = null,
[Description("X точки на ребре, мм")] double? x = null,
[Description("Y точки на ребре, мм")] double? y = null,
[Description("Z точки на ребре, мм")] double? z = null)
[Description("Z точки на ребре, мм")] double? z = null,
[Description("Имя операции в дереве построения, например «Фаска против слоновьей ноги»")] string? name = null)
{
await session.ConnectAsync();
if (edgeIndices is { Length: > 0 })
{
var id = await modeler.ChamferEdgesAsync(edgeIndices, distance);
var id = await modeler.ChamferEdgesAsync(edgeIndices, distance, name);
var count = edgeIndices.Distinct().Count();
return await autoValidation.AnnotateAsync($"Фаска {distance}×{distance} создана на {count} рёбрах, id={id}.");
}
var (px, py, pz) = RequirePoint(x, y, z, "ребре", "edgeIndices");
var byPoint = await modeler.ChamferEdgeAsync(px, py, pz, distance);
var parsed = Point3dInputs.Parse(points);
if (parsed.Count > 0)
{
var id = await modeler.ChamferEdgesByPointsAsync(parsed, distance, name);
return await autoValidation.AnnotateAsync(
$"Фаска {distance}×{distance} создана на {parsed.Count} рёбрах (по точкам), id={id}.");
}
var (px, py, pz) = RequirePoint(x, y, z, "ребре", "edgeIndices/points");
var byPoint = await modeler.ChamferEdgeAsync(px, py, pz, distance, name);
return await autoValidation.AnnotateAsync(
$"Фаска {distance}×{distance} создана на ребре в точке ({px}, {py}, {pz}), id={byPoint}.");
}
@@ -109,29 +179,32 @@ public sealed class FeatureTools(
public async Task<string> Shell(
[Description("Индексы удаляемых (открываемых) граней из list_faces")] int[] faceIndices,
[Description("Толщина стенки, мм")] double thickness,
[Description("Толщина наружу (true) или внутрь (false)")] bool outward = false)
[Description("Толщина наружу (true) или внутрь (false)")] bool outward = false,
[Description("Имя операции в дереве построения, например «Оболочка корпуса 1.6»")] string? name = null)
{
await session.ConnectAsync();
var id = await modeler.ShellAsync(faceIndices, thickness, outward);
var id = await modeler.ShellAsync(faceIndices, thickness, outward, name);
var removed = faceIndices?.Distinct().Count() ?? 0;
return await autoValidation.AnnotateAsync($"Оболочка толщиной {thickness} мм создана (удалено граней: {removed}), id={id}.");
}
[McpServerTool(Name = "rib")]
[Description("Построить ребро жёсткости от разомкнутого контура эскиза до тела. sketchId — эскиз с разомкнутым контуром (отрезок/ломаная) на плоскости, пересекающей тело. thickness — толщина стенки ребра (мм). side — направление достройки полотна до тела (ksRibSideEnum): left|right|up|down (если ребро не строится — перебрать значения). symmetric=true — толщина симметрична плоскости эскиза, false — в одну сторону. angle — уклон стенок (градусы). Возвращает id операции.")]
public async Task<string> Rib(int sketchId, double thickness, string side = "up", bool symmetric = true, double angle = 0)
public async Task<string> Rib(int sketchId, double thickness, string side = "up", bool symmetric = true, double angle = 0,
[Description("Имя операции в дереве построения, например «Ребро жёсткости левое»")] string? name = null)
{
await session.ConnectAsync();
var id = await modeler.RibAsync(sketchId, thickness, side, symmetric, angle);
var id = await modeler.RibAsync(sketchId, thickness, side, symmetric, angle, name);
return await autoValidation.AnnotateAsync($"Ребро жёсткости толщиной {thickness} мм создано (side={side}), id={id}.");
}
[McpServerTool(Name = "sweep")]
[Description("Кинематическая операция: переместить замкнутый профиль (эскиз profileSketchId) вдоль траектории (эскиз pathSketchId), образуя сплошное тело. Профиль и траектория должны быть совместимы: на разных плоскостях (обычно перпендикулярных), начало траектории — на плоскости профиля; иначе операция вернёт ошибку построения. Возвращает id операции.")]
public async Task<string> Sweep(int profileSketchId, int pathSketchId)
public async Task<string> Sweep(int profileSketchId, int pathSketchId,
[Description("Имя операции в дереве построения, например «Труба по траектории»")] string? name = null)
{
await session.ConnectAsync();
var id = await modeler.SweepAsync(profileSketchId, pathSketchId);
var id = await modeler.SweepAsync(profileSketchId, pathSketchId, name);
return await autoValidation.AnnotateAsync(
$"Кинематическая операция создана (профиль {profileSketchId} по траектории {pathSketchId}), id={id}.");
}
@@ -140,10 +213,11 @@ public sealed class FeatureTools(
[Description("Операция по сечениям (loft): построить сплошное тело по нескольким закрытым эскизам-сечениям (sketchIds, минимум 2, в порядке списка). Сечения обычно на параллельных плоскостях — задавайте их через sketch_create с plane + offset. closed=true — замкнуть сечения в кольцо. Возвращает id операции.")]
public async Task<string> Loft(
[Description("Id эскизов-сечений (минимум 2, в порядке построения)")] int[] sketchIds,
[Description("Замкнуть сечения в кольцо")] bool closed = false)
[Description("Замкнуть сечения в кольцо")] bool closed = false,
[Description("Имя операции в дереве построения, например «Переход по сечениям»")] string? name = null)
{
await session.ConnectAsync();
var id = await modeler.LoftAsync(sketchIds, closed);
var id = await modeler.LoftAsync(sketchIds, closed, name);
var sections = sketchIds?.Distinct().Count() ?? 0;
return await autoValidation.AnnotateAsync($"Операция по сечениям создана ({sections} сечений), id={id}.");
}
@@ -162,7 +236,8 @@ public sealed class FeatureTools(
[Description("Число экземпляров всего, включая исходный (>=2)")] int count,
[Description("Шаг между соседними: мм для linear, градусы для circular")] double step,
[Description("Обратное направление вращения (только circular)")] bool reverse = false,
[Description("Геометрическое копирование (быстрее)")] bool geometric = false)
[Description("Геометрическое копирование (быстрее)")] bool geometric = false,
[Description("Имя операции в дереве построения, например «Массив отверстий крепления»")] string? name = null)
{
await session.ConnectAsync();
var circular = ParsePatternKind(kind);
@@ -170,11 +245,11 @@ public sealed class FeatureTools(
if (circular)
{
var id = await modeler.CircularPatternAsync(featureIds, parsedAxis, count, step, reverse, geometric);
var id = await modeler.CircularPatternAsync(featureIds, parsedAxis, count, step, reverse, geometric, name);
return await autoValidation.AnnotateAsync($"Круговой массив создан ({count} экз. вокруг {axis}, шаг {step}°), id={id}.");
}
var linearId = await modeler.LinearPatternAsync(featureIds, parsedAxis, count, step, geometric);
var linearId = await modeler.LinearPatternAsync(featureIds, parsedAxis, count, step, geometric, name);
return await autoValidation.AnnotateAsync($"Линейный массив создан ({count} экз. вдоль {axis}, шаг {step} мм), id={linearId}.");
}
@@ -185,18 +260,19 @@ public sealed class FeatureTools(
"сохраняются). Возвращает id операции.")]
public async Task<string> Mirror(
[Description("Плоскость симметрии: XOY|XOZ|YOZ")] string plane,
[Description("Id отражаемых операций; пусто — отразить всё тело")] int[]? featureIds = null)
[Description("Id отражаемых операций; пусто — отразить всё тело")] int[]? featureIds = null,
[Description("Имя операции в дереве построения, например «Зеркало кронштейна»")] string? name = null)
{
await session.ConnectAsync();
var basePlane = BasePlanes.Parse(plane);
if (featureIds is { Length: > 0 })
{
var id = await modeler.MirrorOperationAsync(featureIds, basePlane);
var id = await modeler.MirrorOperationAsync(featureIds, basePlane, name);
return await autoValidation.AnnotateAsync($"Зеркальная копия операций создана (плоскость {plane}), id={id}.");
}
var bodyId = await modeler.MirrorBodyAsync(basePlane);
var bodyId = await modeler.MirrorBodyAsync(basePlane, name);
return await autoValidation.AnnotateAsync($"Тело зеркально отражено (плоскость {plane}), id={bodyId}.");
}
@@ -224,7 +300,8 @@ public sealed class FeatureTools(
[Description("Глубина цековки, мм — для counterbore")] double? spotfaceDepth = null,
[Description("Диаметр зенковки, мм (> diameter) — для countersink")] double? sinkDiameter = null,
[Description("Угол конуса зенковки, градусы (0..180, типично 90) — для countersink")] double? sinkAngle = null,
[Description("Угол конуса, градусы (0..180) — для conic")] double? conicAngle = null)
[Description("Угол конуса, градусы (0..180) — для conic")] double? conicAngle = null,
[Description("Имя операции в дереве построения, например «Отверстие под M4»")] string? name = null)
{
await session.ConnectAsync();
var kind = (type ?? "simple").Trim().ToLowerInvariant();
@@ -235,26 +312,26 @@ public sealed class FeatureTools(
switch (kind)
{
case "simple":
await holes.HoleAsync(px, py, pz, diameter, depth, throughAll);
await holes.HoleAsync(px, py, pz, diameter, depth, throughAll, name);
return await autoValidation.AnnotateAsync($"Отверстие Ø{diameter} ({how}) создано {where}.");
case "counterbore":
var spotD = Required(spotfaceDiameter, "spotfaceDiameter", kind);
var spotH = Required(spotfaceDepth, "spotfaceDepth", kind);
await holes.CounterboreHoleAsync(px, py, pz, diameter, depth, throughAll, spotD, spotH);
await holes.CounterboreHoleAsync(px, py, pz, diameter, depth, throughAll, spotD, spotH, name);
return await autoValidation.AnnotateAsync(
$"Отверстие с цековкой Ø{diameter} (цековка Ø{spotD}×{spotH}, {how}) создано {where}.");
case "countersink":
var sinkD = Required(sinkDiameter, "sinkDiameter", kind);
var sinkA = Required(sinkAngle, "sinkAngle", kind);
await holes.CountersinkHoleAsync(px, py, pz, diameter, depth, throughAll, sinkD, sinkA);
await holes.CountersinkHoleAsync(px, py, pz, diameter, depth, throughAll, sinkD, sinkA, name);
return await autoValidation.AnnotateAsync(
$"Отверстие с зенковкой Ø{diameter} (зенковка Ø{sinkD}, угол {sinkA}°, {how}) создано {where}.");
case "conic":
var conicA = Required(conicAngle, "conicAngle", kind);
await holes.ConicHoleAsync(px, py, pz, diameter, depth, throughAll, conicA);
await holes.ConicHoleAsync(px, py, pz, diameter, depth, throughAll, conicA, name);
return await autoValidation.AnnotateAsync(
$"Коническое отверстие Ø{diameter} (угол {conicA}°, {how}) создано {where}.");
@@ -270,15 +347,29 @@ public sealed class FeatureTools(
[Description("Индексы уклоняемых граней из list_faces")] int[] faceIndices,
[Description("Нейтральная (опорная) плоскость: XOY|XOZ|YOZ")] string neutralPlane,
[Description("Угол уклона, градусы (0 < angle < 90)")] double angle,
[Description("Расширение (true) или сужение (false)")] bool outward = false)
[Description("Расширение (true) или сужение (false)")] bool outward = false,
[Description("Имя операции в дереве построения, например «Уклон стенок под съём»")] string? name = null)
{
await session.ConnectAsync();
var id = await modeler.DraftAsync(faceIndices, BasePlanes.Parse(neutralPlane), angle, outward);
var id = await modeler.DraftAsync(faceIndices, BasePlanes.Parse(neutralPlane), angle, outward, name);
var count = faceIndices?.Distinct().Count() ?? 0;
return await autoValidation.AnnotateAsync(
$"Уклон {angle}° создан на {count} гранях (нейтральная плоскость {neutralPlane}), id={id}.");
}
[McpServerTool(Name = "feature_delete")]
[Description("Удалить операцию или эскиз по id (из ответа sketch_create/extrude/…) — так убирают " +
"неудачную пробу, не пересобирая документ с нуля. КОМПАС сносит зависимые объекты " +
"КАСКАДОМ: удаляя эскиз, вы удаляете и построенные по нему операции. Удалять можно " +
"только созданное в текущей сессии — после смены документа id недействительны.")]
public async Task<string> FeatureDelete(
[Description("Id эскиза или операции")] int id)
{
await session.ConnectAsync();
var deleted = await modeler.DeleteAsync(id);
return await autoValidation.AnnotateAsync($"{deleted} удалена из дерева.");
}
[McpServerTool(Name = "rebuild")]
[Description("Перестроить активный документ. Нужен перед чтением МЦХ после правок переменных/геометрии.")]
public async Task<string> Rebuild()
+47 -8
View File
@@ -53,11 +53,22 @@ public sealed class QueryTools(KompasSession session, QueryService query, ModelI
[McpServerTool(Name = "list_edges")]
[Description("Рёбра активной детали. Без index — список: индекс, тип кривой " +
"(line/circle/arc/ellipse/nurbs) и длина (мм). С index — подробности этого ребра: длина, " +
"две смежные грани и координаты концевых вершин. Индексы передавайте в fillet_edge / " +
"chamfer_edge — это надёжнее выбора по точке.")]
"(line/circle/arc/ellipse/nurbs), длина (мм) и концы. С index — подробности одного ребра " +
"(длина, смежные грани, концевые вершины). Индексы передавайте в fillet_edge / " +
"chamfer_edge — это надёжнее выбора по точке.\n" +
"ОТБОР: type и координатное окно minX/maxX/minY/maxY/minZ/maxZ оставляют только рёбра, " +
"ОБА конца которых лежат в окне. Так адресуют то, что иначе не выбрать: весь нижний " +
"контур под фаску — list_edges(minZ=-0.01, maxZ=0.01), прямые участки — type=\"line\". " +
"На теле с надписью рёбер сотни, и полный список бесполезен по объёму.")]
public async Task<string> ListEdges(
[Description("Индекс ребра для подробностей; пусто — список всех рёбер")] int? index = null)
[Description("Индекс ребра для подробностей; пусто — список рёбер")] int? index = null,
[Description("Тип кривой: line | circle | arc | ellipse | ellipse_arc | nurbs")] string? type = null,
[Description("Нижняя граница окна по X, мм")] double? minX = null,
[Description("Верхняя граница окна по X, мм")] double? maxX = null,
[Description("Нижняя граница окна по Y, мм")] double? minY = null,
[Description("Верхняя граница окна по Y, мм")] double? maxY = null,
[Description("Нижняя граница окна по Z, мм")] double? minZ = null,
[Description("Верхняя граница окна по Z, мм")] double? maxZ = null)
{
await session.ConnectAsync();
@@ -72,10 +83,38 @@ public sealed class QueryTools(KompasSession session, QueryService query, ModelI
return detail;
}
var edges = await query.ListEdgesAsync();
if (edges.Count == 0) return "Рёбер нет (модель пуста?).";
var lines = edges.Select(e => $" [{e.Index}] {e.Type}, длина {e.Length:F2} мм");
return $"Рёбер: {edges.Count}\n" + string.Join("\n", lines);
var filter = new EdgeFilter
{
Type = type, MinX = minX, MaxX = maxX,
MinY = minY, MaxY = maxY, MinZ = minZ, MaxZ = maxZ,
};
var edges = await query.ListEdgesAsync(filter);
if (edges.Count == 0)
return filter.IsEmpty
? "Рёбер нет (модель пуста?)."
: "Под отбор не попало ни одно ребро — ослабьте окно или уберите type.";
// Разбивка по типам — чтобы не перебирать type вручную вызов за вызовом.
var byType = string.Join(", ", edges.GroupBy(e => e.Type)
.OrderByDescending(g => g.Count())
.Select(g => $"{g.Key} {g.Count()}"));
var head = (filter.IsEmpty ? $"Рёбер: {edges.Count}" : $"Рёбер под отбор: {edges.Count}") + $" ({byType})";
// Готовая строка индексов: она и есть то, что уходит в fillet_edge/chamfer_edge.
var indices = "индексы: " + string.Join(",", edges.Select(e => e.Index));
// Построчно печатаем, только пока список обозрим: на сотнях рёбер это топит ответ,
// а для пакетной операции достаточно индексов.
if (edges.Count > 60)
return $"{head}\n{indices}\n(подробности скрыты — сузьте отбор, чтобы увидеть длины и концы)";
var lines = edges.Select(e =>
{
var row = Inv($" [{e.Index}] {e.Type}, длина {e.Length:F2} мм");
if (e.Start is not { } s || e.End is not { } en) return row;
return row + Inv($", концы ({s.X:F2}, {s.Y:F2}, {s.Z:F2}) → ({en.X:F2}, {en.Y:F2}, {en.Z:F2})");
});
return $"{head}\n{indices}\n" + string.Join("\n", lines);
}
private static string Inv(FormattableString fs) => FormattableString.Invariant(fs);
+20 -11
View File
@@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Modeling;
using ModelContextProtocol.Server;
@@ -17,7 +17,8 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
"на грани в мировых координатах модели (запасной вариант, когда индекс не подходит). " +
"entities — список примитивов, autoClose=true закрывает эскиз сразу, чтобы можно было звать " +
"extrude/revolve. Без entities эскиз остаётся открытым — наполняйте его через sketch_add. " +
"Возвращает id эскиза.")]
"name — имя эскиза в дереве построения («Контур плашки»): давайте его ВСЕГДА, иначе дерево " +
"состоит из безликих «Эскиз:1…N» и по нему нельзя понять, что где. Возвращает id эскиза.")]
public async Task<string> Create(
[Description("Базовая плоскость: XOY | XOZ | YOZ")] string? plane = null,
[Description("Индекс грани из list_faces (надёжнее выбора по точке)")] int? faceIndex = null,
@@ -27,7 +28,8 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
[Description("Смещение от базовой плоскости, мм (только вместе с plane)")] double offset = 0,
[Description("Прямое направление смещения от базовой плоскости")] bool offsetDirection = true,
[Description("Примитивы эскиза: список объектов {type, points, centerX, ...}")] SketchEntityInput[]? entities = null,
[Description("Закрыть эскиз после построения entities (нужно перед операцией)")] bool autoClose = true)
[Description("Закрыть эскиз после построения entities (нужно перед операцией)")] bool autoClose = true,
[Description("Имя эскиза в дереве построения, например «Контур плашки»")] string? name = null)
{
await session.ConnectAsync();
var parsed = SketchEntityInputs.Parse(entities);
@@ -36,12 +38,12 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
string on;
if (faceIndex is { } fi)
{
id = await modeler.OpenSketchOnFaceIndexAsync(fi);
id = await modeler.OpenSketchOnFaceIndexAsync(fi, name);
on = $"грани [{fi}]";
}
else if (x is { } px && y is { } py && z is { } pz)
{
id = await modeler.OpenSketchOnFaceAsync(px, py, pz);
id = await modeler.OpenSketchOnFaceAsync(px, py, pz, name);
on = $"грани в точке ({px}, {py}, {pz})";
}
else if (!string.IsNullOrWhiteSpace(plane))
@@ -49,12 +51,12 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
var basePlane = BasePlanes.Parse(plane);
if (offset == 0)
{
id = await modeler.OpenSketchAsync(basePlane);
id = await modeler.OpenSketchAsync(basePlane, name);
on = $"плоскости {plane}";
}
else
{
id = await modeler.OpenSketchOnOffsetPlaneAsync(basePlane, offset, offsetDirection);
id = await modeler.OpenSketchOnOffsetPlaneAsync(basePlane, offset, offsetDirection, name);
on = $"плоскости {plane}, смещение {offset} мм";
}
}
@@ -69,9 +71,9 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
return $"Эскиз id={id} создан на {on} и открыт. Добавьте геометрию через sketch_add.";
// Пакет строится за один заход на COM-поток; autoClose закрывает эскиз там же.
await modeler.AddEntitiesAsync(id, parsed, autoClose);
var notes = await modeler.AddEntitiesAsync(id, parsed, autoClose);
var state = autoClose ? "закрыт — можно строить операцию" : "остался открытым";
return $"Эскиз id={id} создан на {on}, добавлено примитивов: {parsed.Count}, {state}.";
return $"Эскиз id={id} создан на {on}, добавлено примитивов: {parsed.Count}, {state}.{Notes(notes)}";
}
[McpServerTool(Name = "sketch_add")]
@@ -90,9 +92,16 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
if (parsed.Count == 0)
throw new ArgumentException("Список entities пуст — нечего добавлять.");
await modeler.AddEntitiesAsync(sketchId, parsed, autoClose);
var notes = await modeler.AddEntitiesAsync(sketchId, parsed, autoClose);
var state = autoClose ? " Эскиз закрыт." : "";
return $"В эскиз id={sketchId} добавлено примитивов: {parsed.Count}.{state}";
return $"В эскиз id={sketchId} добавлено примитивов: {parsed.Count}.{state}{Notes(notes)}";
}
/// <summary>Заметки по построенным примитивам (сейчас — метрики надписей: габарит глифов и ячейка).</summary>
private static string Notes(IReadOnlyList<string> notes)
{
var meaningful = notes.Where(n => !string.IsNullOrEmpty(n)).ToList();
return meaningful.Count == 0 ? "" : " " + string.Join("; ", meaningful) + ".";
}
[McpServerTool(Name = "sketch_close")]
+27 -3
View File
@@ -1,14 +1,15 @@
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Diagnostics;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Validation;
using ModelContextProtocol.Server;
namespace Kompas.Mcp.Host.Tools;
/// <summary>Системные инструменты: подключение, статус, видимость окна.</summary>
/// <summary>Системные инструменты: подключение, статус, видимость окна, журнал операций.</summary>
[McpServerToolType]
public sealed class SystemTools(KompasSession session, AutoValidation autoValidation)
public sealed class SystemTools(KompasSession session, AutoValidation autoValidation, OperationLog operationLog)
{
[McpServerTool(Name = "kompas_connect")]
[Description("Подключиться к КОМПАС-3D: присоединиться к запущенному экземпляру или запустить новый, показать окно. Возвращает версию и редакцию.")]
@@ -41,7 +42,30 @@ public sealed class SystemTools(KompasSession session, AutoValidation autoValida
return (session.IsConnected && session.LastInfo is { } i
? $"Подключено: КОМПАС {i.Version} ({i.Edition})."
: "Не подключено. Вызовите kompas_connect.") + " " + validation;
: "Не подключено. Вызовите kompas_connect.") + " " + validation + " " + operationLog.Describe();
}
[McpServerTool(Name = "set_operation_log")]
[Description("Включить или выключить журнал операций: запись каждого вызова инструмента в файл JSONL " +
"(имя инструмента, аргументы, длительность, успех или текст ошибки, начало ответа). " +
"Журнал нужен для накопления истории работы — по ней видно, какие инструменты реально " +
"используются и на чём спотыкаются. Вызывайте по прямой просьбе пользователя; путь задаётся " +
"параметром path или переменной окружения KOMPAS_MCP_OPLOG_PATH (без пути журнал выключен). " +
"Снимки модели в журнал не пишутся — только их размер.")]
public string SetOperationLog(
[Description("true — включить запись, false — выключить")] bool enabled,
[Description("Файл журнала (.jsonl) или каталог для него. Пусто — прошлый путь либо KOMPAS_MCP_OPLOG_PATH")]
string? path = null)
{
if (!enabled)
{
operationLog.Disable();
return operationLog.Describe();
}
var file = operationLog.Enable(path);
return $"Журнал операций включён: {file}. Пишется по строке JSON на вызов инструмента " +
$"(записей за сессию: {operationLog.WrittenCount}).";
}
[McpServerTool(Name = "set_auto_validate")]
+63
View File
@@ -0,0 +1,63 @@
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Отбор рёбер. На теле с надписью их сотни: без окна нельзя адресовать «весь нижний контур»,
/// а полный список бесполезен по объёму ответа.
/// </summary>
[Trait("Category", "Unit")]
public sealed class EdgeFilterTests
{
private static EdgeInfo Edge(int index, string type,
(double, double, double)? start, (double, double, double)? end, double length = 10)
=> new() { Index = index, Type = type, Length = length, Start = start, End = end };
[Fact]
public void Empty_filter_takes_everything()
{
var filter = new EdgeFilter();
Assert.True(filter.IsEmpty);
Assert.True(filter.Matches(Edge(0, "line", (0, 0, 0), (10, 0, 0))));
Assert.True(filter.Matches(Edge(1, "nurbs", null, null)));
}
[Fact]
public void Window_keeps_only_edges_with_both_ends_inside()
{
var bottom = new EdgeFilter { MinZ = -0.01, MaxZ = 0.01 };
Assert.True(bottom.Matches(Edge(0, "line", (0, 0, 0), (10, 0, 0)))); // весь низ
Assert.False(bottom.Matches(Edge(1, "line", (0, 0, 0), (0, 0, 3)))); // вертикальное
Assert.False(bottom.Matches(Edge(2, "line", (0, 0, 3), (10, 0, 3)))); // весь верх
}
[Fact]
public void Edge_without_vertices_cannot_satisfy_a_window()
{
var filter = new EdgeFilter { MinZ = 0, MaxZ = 0 };
Assert.False(filter.Matches(Edge(0, "nurbs", null, null)));
}
[Fact]
public void Type_filter_is_case_insensitive_and_independent_of_the_window()
{
var lines = new EdgeFilter { Type = " LINE " };
Assert.False(lines.IsEmpty);
Assert.True(lines.Matches(Edge(0, "line", (0, 0, 0), (10, 0, 0))));
Assert.False(lines.Matches(Edge(1, "circle", (0, 0, 0), (0, 0, 0))));
Assert.True(lines.Matches(Edge(2, "line", null, null))); // без окна координаты не нужны
}
[Fact]
public void Window_and_type_are_combined()
{
var filter = new EdgeFilter { Type = "line", MinZ = -0.01, MaxZ = 0.01 };
Assert.True(filter.Matches(Edge(0, "line", (0, 0, 0), (10, 0, 0))));
Assert.False(filter.Matches(Edge(1, "arc", (0, 0, 0), (10, 0, 0))));
Assert.False(filter.Matches(Edge(2, "line", (0, 0, 0), (10, 0, 3))));
}
}
@@ -0,0 +1,50 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Имя операции в дереве построения. Дерево из «Эскиз:1…Эскиз:5» нечитаемо, поэтому имя
/// принимается у каждой операции — но кривое имя (с переводом строки, гигантское) в дереве
/// отображается искажённо, и лучше сказать об этом сразу.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FeatureNameTests
{
[Fact]
public void Name_is_trimmed()
=> Assert.Equal("Контур плашки", FeatureName.Normalize(" Контур плашки "));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Empty_name_means_leave_the_kompas_default(string? name)
=> Assert.Null(FeatureName.Normalize(name));
[Fact]
public void Too_long_name_is_rejected_with_the_limit_in_the_message()
{
var ex = Assert.Throws<ArgumentException>(() => FeatureName.Normalize(new string('я', FeatureName.MaxLength + 1)));
Assert.Contains(FeatureName.MaxLength.ToString(), ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Name_at_the_limit_is_accepted()
{
var name = new string('я', FeatureName.MaxLength);
Assert.Equal(name, FeatureName.Normalize(name));
}
[Theory]
[InlineData("Эскиз\nвторая строка")]
[InlineData("Эскиз\tтабуляция")]
public void Control_characters_are_rejected(string name)
=> Assert.Throws<ArgumentException>(() => FeatureName.Normalize(name));
[Fact]
public void Parameter_name_reaches_the_message()
{
var ex = Assert.Throws<ArgumentException>(() => FeatureName.Normalize("a\nb", "sketchName"));
Assert.Equal("sketchName", ex.ParamName);
}
}
@@ -31,6 +31,41 @@ public sealed class InspectionTextTests
public void GroupTypes_empty_returns_dash()
=> Assert.Equal("—", InspectionText.GroupTypes(Array.Empty<string>()));
// ── Наименование/обозначение детали в заголовке ──────────────────────
private static ModelDescription Desc(string partName, string partMarking) => new()
{
Name = "part.m3d", PartName = partName, PartMarking = partMarking,
IsEmpty = true, DocumentTypeName = "деталь",
Bodies = [], FaceTypes = [], EdgeTypes = [], Features = [], Variables = [], Imported = false,
};
[Fact]
public void Part_without_a_name_is_flagged_with_the_way_to_set_it()
{
var s = InspectionText.PartIdentityText(Desc("", ""));
Assert.Contains("без наименования", s, StringComparison.Ordinal);
Assert.Contains("set_part_info", s, StringComparison.Ordinal);
}
[Fact]
public void Part_identity_shows_both_name_and_marking()
{
var s = InspectionText.PartIdentityText(Desc("Значок декоративный", "ОМЕ.741124.001"));
Assert.Contains("«Значок декоративный»", s, StringComparison.Ordinal);
Assert.Contains("«ОМЕ.741124.001»", s, StringComparison.Ordinal);
}
[Fact]
public void Part_identity_omits_the_empty_marking()
{
var s = InspectionText.PartIdentityText(Desc("Значок декоративный", " "));
Assert.Contains("наименование", s, StringComparison.Ordinal);
Assert.DoesNotContain("обозначение", s, StringComparison.Ordinal);
}
// ── Детект импорта без истории ───────────────────────────────────────
private static FeatureInfo Feat(int i, string name, string kind) => new() { Index = i, Name = name, Kind = kind };
@@ -30,6 +30,70 @@ public sealed class DocumentTests : IntegrationTestBase
Assert.True(File.Exists(path));
}
[Fact]
public async Task SaveAs_over_a_name_held_by_another_open_document_fails_loudly()
{
// КОМПАС не перезаписывает файл, открытый другим документом, и НИЧЕГО об этом не сообщает:
// SaveAs возвращает void. Без проверки по факту агент получает ложное «Сохранено».
var path = TestPaths.NewFile(".m3d");
await _docs.CreateAsync(KompasDocumentType.Part);
await _docs.SaveAsAsync(path); // первый документ занял имя и остался открытым
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _docs.SaveAsAsync(path));
Assert.Contains(path, ex.Message, StringComparison.OrdinalIgnoreCase);
}
finally
{
await _docs.CloseAsync(save: false);
await _docs.CloseAsync(save: false);
}
}
[Fact]
public async Task SaveAs_creates_the_missing_directory()
{
// КОМПАС каталогов не создаёт: SaveAs в несуществующую папку молча ничего не пишет,
// а прежняя диагностика винила «занятое имя файла» — агент чинил не ту причину.
var path = Path.Combine(Path.GetDirectoryName(TestPaths.NewFile(".m3d"))!,
"подпапка кейса", "деталь.m3d");
Assert.False(Directory.Exists(Path.GetDirectoryName(path)));
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var saved = await _docs.SaveAsAsync(path);
Assert.Equal(path, saved, ignoreCase: true);
Assert.True(File.Exists(path), "файл не появился на диске");
}
finally
{
await _docs.CloseAsync(save: false);
try { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); } catch (IOException) { }
}
}
[Fact]
public async Task Part_name_and_marking_survive_the_write()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var written = await _docs.SetPartIdentityAsync("Значок декоративный", "ОМЕ.741124.001");
Assert.Equal("Значок декоративный", written.Name);
Assert.Equal("ОМЕ.741124.001", written.Marking);
// Незаданный параметр не затирает уже записанное.
var partial = await _docs.SetPartIdentityAsync(name: null, marking: "ОМЕ.741124.002");
Assert.Equal("Значок декоративный", partial.Name);
Assert.Equal("ОМЕ.741124.002", partial.Marking);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Create_fragment_reports_type()
{
@@ -0,0 +1,151 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Интеграция: удаление операции/эскиза. Без него любая неудачная проба (не тот кегль надписи,
/// не та глубина) оставалась в дереве навсегда, и единственным выходом было закрыть документ
/// без сохранения и построить всё заново.
/// </summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class FeatureDeleteTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PartModeler _modeler;
private readonly ModelInspectionService _inspection;
private readonly QueryService _query;
public FeatureDeleteTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_inspection = new ModelInspectionService(fx.Session, fx.Dispatcher);
_query = new QueryService(fx.Session, fx.Dispatcher);
}
private async Task<IReadOnlyList<string>> TreeNamesAsync()
=> (await _inspection.DescribeModelAsync(InspectionText.ModelSections.Tree))
.Features.Select(f => f.Name).ToList();
[Fact]
public async Task Deleted_extrusion_leaves_the_tree_and_gives_the_material_back()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var plate = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Контур плиты");
await _modeler.AddRectangleAsync(plate, 0, 0, 20, 10);
await _modeler.CloseSketchAsync(plate);
await _modeler.ExtrudeAsync(plate, depth: 3, name: "Плита");
var probe = await _modeler.OpenSketchOnOffsetPlaneAsync(BasePlane.XOY, offset: 3, name: "Проба кегля");
await _modeler.AddRectangleAsync(probe, 2, 2, 8, 8);
await _modeler.CloseSketchAsync(probe);
var probeId = await _modeler.ExtrudeAsync(probe, depth: 5, name: "Неудачная проба");
await _modeler.RebuildAsync();
var withProbe = (await _query.GetPartInfoAsync()).Volume;
var deleted = await _modeler.DeleteAsync(probeId);
await _modeler.RebuildAsync();
Assert.Contains("Неудачная проба", deleted, StringComparison.Ordinal);
var names = await TreeNamesAsync();
Assert.DoesNotContain("Неудачная проба", names);
Assert.Contains("Плита", names);
// 20×10×3 = 600: проба ушла вместе со своим материалом (6×6×5 = 180).
var after = (await _query.GetPartInfoAsync()).Volume;
Assert.InRange(after, 594, 606);
Assert.True(withProbe > after, "проба не добавляла материала — тест ничего не проверяет");
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Deleting_a_sketch_takes_its_operation_with_it()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Контур пробы");
await _modeler.AddRectangleAsync(s, 0, 0, 20, 10);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 3, name: "Тело пробы");
await _modeler.RebuildAsync();
await _modeler.DeleteAsync(s); // КОМПАС удаляет зависимые каскадом
await _modeler.RebuildAsync();
var names = await TreeNamesAsync();
Assert.DoesNotContain("Контур пробы", names);
Assert.DoesNotContain("Тело пробы", names);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Deleting_a_sketch_takes_its_offset_plane_with_it()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Тело нужно, чтобы дерево вообще перечислялось: у детали без тел оно пустое.
var plate = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Контур плиты");
await _modeler.AddRectangleAsync(plate, 0, 0, 20, 10);
await _modeler.CloseSketchAsync(plate);
await _modeler.ExtrudeAsync(plate, depth: 3, name: "Плита");
// Плоскость создаётся РАДИ эскиза, её id наружу не выдаётся — останься она в дереве,
// убрать её агенту было бы нечем.
var s = await _modeler.OpenSketchOnOffsetPlaneAsync(BasePlane.XOY, offset: 5, name: "Проба на смещении");
await _modeler.AddRectangleAsync(s, 2, 2, 8, 8);
await _modeler.CloseSketchAsync(s);
await _modeler.RebuildAsync();
var before = await TreeNamesAsync();
Assert.Contains(before, n => n.Contains("мещ", StringComparison.OrdinalIgnoreCase)); // плоскость в дереве есть
var deleted = await _modeler.DeleteAsync(s);
await _modeler.RebuildAsync();
Assert.Contains("плоскост", deleted, StringComparison.OrdinalIgnoreCase);
var names = await TreeNamesAsync();
Assert.DoesNotContain("Проба на смещении", names);
Assert.DoesNotContain(names, n => n.Contains("мещ", StringComparison.OrdinalIgnoreCase)); // «Смещенная плоскость:N»
Assert.Contains("Плита", names);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Unknown_id_says_what_can_be_deleted()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var ex = await Assert.ThrowsAsync<KeyNotFoundException>(() => _modeler.DeleteAsync(4242));
Assert.Contains("4242", ex.Message, StringComparison.Ordinal);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Open_sketch_is_closed_before_deletion()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Эскиз остался в режиме редактирования — удаление обязано с этим справиться само.
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Открытая проба");
await _modeler.AddRectangleAsync(s, 0, 0, 10, 10);
await _modeler.DeleteAsync(s);
await _modeler.RebuildAsync();
Assert.DoesNotContain("Открытая проба", await TreeNamesAsync());
}
finally { await _docs.CloseAsync(save: false); }
}
}
@@ -0,0 +1,86 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Интеграция: имя операции в дереве построения. Без имени дерево — «Эскиз:1», «Элемент
/// выдавливания:1», и вернувшийся к модели агент (или человек) не знает, что чем построено.
/// Имя ставится ДО Create()/Update() и должно читаться обратно через дерево (list_features).
/// </summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class FeatureNamingTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PartModeler _modeler;
private readonly PrimitiveService _primitives;
private readonly ModelInspectionService _inspection;
public FeatureNamingTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_primitives = new PrimitiveService(fx.Session, fx.Dispatcher);
_inspection = new ModelInspectionService(fx.Session, fx.Dispatcher);
}
private async Task<IReadOnlyList<string>> TreeNamesAsync()
=> (await _inspection.DescribeModelAsync(InspectionText.ModelSections.Tree))
.Features.Select(f => f.Name).ToList();
[Fact]
public async Task Sketch_and_extrusion_carry_their_names_into_the_tree()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Контур плашки");
await _modeler.AddRectangleAsync(s, 0, 0, 20, 10);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 3, name: "Плашка 20×10×3");
await _modeler.RebuildAsync();
var names = await TreeNamesAsync();
Assert.Contains("Контур плашки", names);
Assert.Contains("Плашка 20×10×3", names);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Primitive_carries_its_name_into_the_tree()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
await _primitives.PrimitiveAsync(PrimitiveKind.Block, 0, 0, 0,
length: 20, width: 10, height: 3, name: "Основание корпуса");
var names = await TreeNamesAsync();
Assert.Contains("Основание корпуса", names);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Without_a_name_kompas_keeps_its_own()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddRectangleAsync(s, 0, 0, 20, 10);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 3);
await _modeler.RebuildAsync();
// Имя не задано — дерево остаётся с системными именами, но узлы на месте.
var names = await TreeNamesAsync();
Assert.NotEmpty(names);
Assert.All(names, n => Assert.False(string.IsNullOrWhiteSpace(n)));
}
finally { await _docs.CloseAsync(save: false); }
}
}
@@ -0,0 +1,88 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Интеграция: скругление пакетом по ТОЧКАМ. На теле с сотнями рёбер (надпись!) список из
/// list_edges неподъёмен по объёму ответа, а выбор по точке раньше брал ровно одно ребро —
/// четыре угла давали четыре операции в дереве. Теперь это одна операция.
/// </summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class FilletByPointsTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PartModeler _modeler;
private readonly ModelInspectionService _inspection;
private readonly QueryService _query;
public FilletByPointsTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_inspection = new ModelInspectionService(fx.Session, fx.Dispatcher);
_query = new QueryService(fx.Session, fx.Dispatcher);
}
/// <summary>Плита 20×10×3 от начала координат: вертикальные рёбра — в её четырёх углах.</summary>
private async Task PlateAsync()
{
await _docs.CreateAsync(KompasDocumentType.Part);
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Контур плиты");
await _modeler.AddRectangleAsync(s, 0, 0, 20, 10);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 3, name: "Плита");
await _modeler.RebuildAsync();
}
[Fact]
public async Task Four_corners_become_one_fillet_in_the_tree()
{
await PlateAsync();
try
{
var before = (await _query.GetPartInfoAsync()).Volume;
await _modeler.FilletEdgesByPointsAsync(
[
new Point3d(0, 0, 1.5), new Point3d(20, 0, 1.5),
new Point3d(20, 10, 1.5), new Point3d(0, 10, 1.5),
], radius: 2, name: "Скругления углов плиты");
await _modeler.RebuildAsync();
var tree = (await _inspection.DescribeModelAsync(InspectionText.ModelSections.Tree)).Features;
Assert.Single(tree, f => f.Name == "Скругления углов плиты");
// Каждый угол теряет (4 − π)·r²/4 · h материала — проверяем, что скруглены все четыре.
var expected = before - 4 * (4 - Math.PI) / 4 * 2 * 2 * 3;
Assert.InRange((await _query.GetPartInfoAsync()).Volume, expected * 0.99, expected * 1.01);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Points_without_an_edge_are_all_listed_at_once()
{
await PlateAsync();
try
{
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _modeler.FilletEdgesByPointsAsync(
[
new Point3d(0, 0, 1.5), // ребро есть
new Point3d(100, 100, 100), // пусто
new Point3d(-50, -50, -50), // пусто
], radius: 1));
// Обе промахнувшиеся точки названы в ОДНОМ сообщении: иначе агент выясняет их по одной.
Assert.Contains("[1]", ex.Message, StringComparison.Ordinal);
Assert.Contains("[2]", ex.Message, StringComparison.Ordinal);
// Модель не тронута — операция не создавалась.
var tree = (await _inspection.DescribeModelAsync(InspectionText.ModelSections.Tree)).Features;
Assert.DoesNotContain(tree, f => f.Name.Contains("Скругление", StringComparison.OrdinalIgnoreCase));
}
finally { await _docs.CloseAsync(save: false); }
}
}
@@ -54,7 +54,7 @@ public sealed class MergedToolPathsTests : IntegrationTestBase
Rect(0, 0, 40, 20),
Circle(5, 5, 2), Circle(35, 5, 2), Circle(5, 15, 2), Circle(35, 15, 2),
], autoClose: true);
Assert.Equal(5, added);
Assert.Equal(5, added.Count);
await _modeler.ExtrudeAsync(s, depth: 10);
await _modeler.RebuildAsync();
@@ -0,0 +1,116 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Интеграция: элементарные тела (API7 <c>IElementaryBodies</c>) — второй путь формообразования
/// рядом с «эскиз → выдавливание», в том числе вычитание примитива вместо выреза по эскизу.
/// </summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class PrimitiveTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PrimitiveService _primitives;
private readonly PartModeler _modeler;
private readonly QueryService _query;
public PrimitiveTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_primitives = new PrimitiveService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_query = new QueryService(fx.Session, fx.Dispatcher);
}
[Fact]
public async Task Block_is_built_by_sizes_from_the_anchor_corner()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
await _primitives.PrimitiveAsync(PrimitiveKind.Block, x: 4, y: 0, z: 0,
length: 82, width: 10.5, height: 3);
await _modeler.RebuildAsync();
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.MinX, 3.99, 4.01); // точка привязки — угол, а не центр
Assert.InRange(box.SizeX, 81.9, 82.1);
Assert.InRange(box.SizeY, 10.4, 10.6);
Assert.InRange(box.SizeZ, 2.99, 3.01);
var v = (await _query.GetPartInfoAsync()).Volume; // 82·10.5·3 = 2583
Assert.InRange(v, 2583 * 0.99, 2583 * 1.01);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Subtracted_block_hollows_a_pocket_without_a_sketch()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
await _primitives.PrimitiveAsync(PrimitiveKind.Block, 0, 0, 0, length: 40, width: 20, height: 10);
// Карман 30×10 глубиной 4 — вычитаем блок, приподняв его до дна кармана.
await _primitives.PrimitiveAsync(PrimitiveKind.Block, 5, 5, 6, length: 30, width: 10, height: 10,
result: PrimitiveResult.Subtract);
await _modeler.RebuildAsync();
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.SizeZ, 9.9, 10.1); // габарит не изменился — вырезано внутри
var v = (await _query.GetPartInfoAsync()).Volume; // 40·20·10 30·10·4 = 6800
Assert.InRange(v, 6800 * 0.99, 6800 * 1.01);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Cylinder_can_be_added_to_an_existing_body()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
await _primitives.PrimitiveAsync(PrimitiveKind.Block, 0, 0, 0, length: 20, width: 20, height: 5);
await _primitives.PrimitiveAsync(PrimitiveKind.Cylinder, 10, 10, 5, diameter: 8, height: 10,
result: PrimitiveResult.Union);
await _modeler.RebuildAsync();
var bodies = await _query.ListFacesAsync();
Assert.NotEmpty(bodies);
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.SizeZ, 14.9, 15.1); // 5 плиты + 10 цилиндра
var v = (await _query.GetPartInfoAsync()).Volume; // 20·20·5 + π·4²·10 ≈ 2502.7
var expected = 20 * 20 * 5 + Math.PI * 16 * 10;
Assert.InRange(v, expected * 0.98, expected * 1.02);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Subtracting_where_nothing_intersects_fails_with_a_hint()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
await _primitives.PrimitiveAsync(PrimitiveKind.Block, 0, 0, 0, length: 10, width: 10, height: 10);
// Update() при вычитании мимо тела возвращает TRUE — ловим по неизменившемуся объёму.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
_primitives.PrimitiveAsync(PrimitiveKind.Sphere, 100, 100, 100, diameter: 5,
result: PrimitiveResult.Subtract));
Assert.Contains("не удалило материал", ex.Message, StringComparison.Ordinal);
// Неудачная операция не должна оставаться в дереве: объём прежний, тело одно.
var v = (await _query.GetPartInfoAsync()).Volume;
Assert.InRange(v, 1000 * 0.99, 1000 * 1.01);
}
finally { await _docs.CloseAsync(save: false); }
}
}
@@ -0,0 +1,156 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Интеграция: надпись в эскизе. Текст сам по себе — оформление, выдавить его нельзя;
/// примитив type=text переводит его в кривые (ksConvertTextToCurve), и контур глифов
/// становится обычным сечением операции.
/// </summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class SketchTextTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PartModeler _modeler;
private readonly QueryService _query;
public SketchTextTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_query = new QueryService(fx.Session, fx.Dispatcher);
}
private static SketchEntity Text(string text, double x, double y, double height, string? font = null) => new()
{
Kind = SketchEntityKind.Text,
Points = [(x, y)],
Text = text,
Height = height,
FontName = font,
};
[Fact]
public async Task Text_becomes_curves_and_extrudes()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
var notes = await _modeler.AddEntitiesAsync(s, [Text("HI", 0, 0, 10, "Arial")], autoClose: true);
await _modeler.ExtrudeAsync(s, depth: 2);
await _modeler.RebuildAsync();
// Метрики нужны агенту для выравнивания: до выдавливания их узнать неоткуда.
Assert.Contains("глифы", notes[0], StringComparison.Ordinal);
Assert.Contains("ширина ячейки", notes[0], StringComparison.Ordinal);
var info = await _query.GetPartInfoAsync();
Assert.True(info.Volume > 0, "надпись не дала объёма — текст не переведён в кривые");
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.SizeZ, 1.99, 2.01);
Assert.InRange(box.SizeY, 6, 11); // высота прописных ≈ height
Assert.True(box.SizeX > box.SizeY); // «HI» шире, чем выше
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Reported_glyph_box_matches_the_extruded_geometry()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Ширина ячейки (её и возвращали раньше) больше габарита глифов, поэтому агент,
// вписывающий надпись в поле, промахивался. Сверяем заявленный габарит с фактическим.
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
var notes = await _modeler.AddEntitiesAsync(s, [Text("HH", 0, 0, 10, "Arial")], autoClose: true);
await _modeler.ExtrudeAsync(s, depth: 2);
await _modeler.RebuildAsync();
var numbers = System.Text.RegularExpressions.Regex
.Matches(notes[0], @"-?\d+\.\d{2}")
.Select(m => double.Parse(m.Value, System.Globalization.CultureInfo.InvariantCulture))
.ToList();
Assert.True(numbers.Count >= 7, $"в заметке нет чисел габарита: {notes[0]}");
var (minX, maxX, minY, maxY) = (numbers[0], numbers[1], numbers[2], numbers[3]);
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(maxX - minX, box.SizeX - 0.05, box.SizeX + 0.05);
Assert.InRange(maxY - minY, box.SizeY - 0.05, box.SizeY + 0.05);
// Единицы — миллиметры эскиза, а не сантиметры листа: высота прописной ≈ height.
Assert.InRange(maxY - minY, 9.5, 10.5);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Unknown_font_is_reported_by_name()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
var bad = Text("HI", 0, 0, 10, "No Such Font 12345");
// КОМПАС подставляет замену вместо отсутствующего шрифта, поэтому надпись
// всё равно строится — важно, что она не рушит пакет и даёт геометрию.
var notes = await _modeler.AddEntitiesAsync(s, [bad], autoClose: true);
Assert.False(string.IsNullOrEmpty(notes[0]));
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Thin_wall_outward_grows_the_contour()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Квадрат 20×20 стенкой 2 мм наружу: габарит вырастает на 2·2 мм по каждой оси,
// а объём равен объёму рамки, а не сплошной плиты.
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddRectangleAsync(s, 0, 0, 20, 20);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 5, thinThickness: 2, thinSide: ThinSide.Outward);
await _modeler.RebuildAsync();
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.SizeX, 23.9, 24.1);
Assert.InRange(box.SizeY, 23.9, 24.1);
var v = (await _query.GetPartInfoAsync()).Volume; // (24² 20²)·5 = 880
Assert.InRange(v, 880 * 0.98, 880 * 1.02);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Thin_wall_inward_keeps_the_contour_and_hollows_it()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Стенка внутрь контура: габарит остаётся 20×20, материал — только рамка по периметру.
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddRectangleAsync(s, 0, 0, 20, 20);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: 5, thinThickness: 2, thinSide: ThinSide.Inward);
await _modeler.RebuildAsync();
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(box.SizeX, 19.9, 20.1);
Assert.InRange(box.SizeY, 19.9, 20.1);
// (20² 16²)·5 = 720; сплошная плита дала бы 2000 — так ловится «стенка не применилась».
var v = (await _query.GetPartInfoAsync()).Volume;
Assert.InRange(v, 720 * 0.98, 720 * 1.02);
}
finally { await _docs.CloseAsync(save: false); }
}
}
@@ -0,0 +1,106 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
using System.Globalization;
using System.Text.RegularExpressions;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Интеграция: выравнивание надписи. КОМПАС кладёт строку только вправо-вверх от точки вставки
/// (<c>align</c> у <c>ksTextEx</c> её не двигает), поэтому центрирование делает сервер: строит,
/// замеряет, удаляет и кладёт заново со сдвигом.
/// </summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class TextAlignmentTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PartModeler _modeler;
private readonly QueryService _query;
public TextAlignmentTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_query = new QueryService(fx.Session, fx.Dispatcher);
}
private static SketchEntity Text(string text, double x, double y, TextAlign align, TextVAlign vAlign) => new()
{
Kind = SketchEntityKind.Text,
Points = [(x, y)],
Text = text,
Height = 10,
FontName = "Arial",
Align = align,
VAlign = vAlign,
};
/// <summary>Числа из заметки: minX, maxX, minY, maxY.</summary>
private static (double MinX, double MaxX, double MinY, double MaxY) Box(string note)
{
var n = Regex.Matches(note, @"-?\d+\.\d{2}")
.Select(m => double.Parse(m.Value, CultureInfo.InvariantCulture)).ToList();
Assert.True(n.Count >= 4, $"в заметке нет габарита: {note}");
return (n[0], n[1], n[2], n[3]);
}
[Fact]
public async Task Centered_text_puts_its_middle_on_the_insertion_point()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Надпись по центру");
var notes = await _modeler.AddEntitiesAsync(
s, [Text("HHH", 50, 0, TextAlign.Center, TextVAlign.Baseline)], autoClose: true);
var box = Box(notes[0]);
Assert.InRange((box.MinX + box.MaxX) / 2, 49.95, 50.05);
// Надпись осталась ОДНА: первая вставка удалена, а не оставлена рядом.
await _modeler.ExtrudeAsync(s, depth: 2, name: "Рельеф");
await _modeler.RebuildAsync();
var modelBox = await _query.GetBoundingBoxAsync();
var what = $"заметка: {notes[0]}; модель X {modelBox.MinX:F2}…{modelBox.MaxX:F2}";
Assert.True(Math.Abs(modelBox.MinX - box.MinX) < 0.05, what);
Assert.True(Math.Abs(modelBox.MaxX - box.MaxX) < 0.05, what);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Right_and_middle_anchor_the_box_edges()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Надпись по правому краю");
var notes = await _modeler.AddEntitiesAsync(
s, [Text("Hy", 90, 20, TextAlign.Right, TextVAlign.Middle)], autoClose: true);
var box = Box(notes[0]);
Assert.InRange(box.MaxX, 89.95, 90.05); // правый край — на якоре
Assert.InRange((box.MinY + box.MaxY) / 2, 19.95, 20.05); // середина глифов — на якоре
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Default_alignment_keeps_the_kompas_behaviour()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Надпись от точки");
var notes = await _modeler.AddEntitiesAsync(
s, [Text("HH", 5, 0, TextAlign.Left, TextVAlign.Baseline)], autoClose: true);
var box = Box(notes[0]);
Assert.True(box.MinX >= 5, "по умолчанию строка кладётся вправо от точки вставки");
Assert.InRange(box.MinY, -0.5, 0.5); // базовая линия — на y точки вставки
}
finally { await _docs.CloseAsync(save: false); }
}
}
@@ -0,0 +1,121 @@
using System.Text.Json;
using Kompas.Mcp.Core.Diagnostics;
namespace Kompas.Mcp.Tests;
/// <summary>Формат строки журнала: одна строка JSON, кириллица не эскейпится, ответ усечён.</summary>
[Trait("Category", "Unit")]
public sealed class OperationLogEntryTests
{
private static OperationLogEntry Entry(
string tool = "extrude",
string? arguments = null,
bool ok = true,
string? result = null,
string? error = null)
=> new()
{
Timestamp = new DateTimeOffset(2026, 7, 31, 10, 20, 30, 456, TimeSpan.Zero),
Tool = tool,
Arguments = arguments is null ? null : Parse(arguments),
Duration = TimeSpan.FromMilliseconds(412),
Ok = ok,
Result = result,
Error = error,
};
private static Dictionary<string, JsonElement> Parse(string json)
=> JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!;
private static JsonElement Json(OperationLogEntry entry)
=> JsonDocument.Parse(entry.ToJson()).RootElement;
[Fact]
public void Writes_single_line()
{
var json = Entry(result: "Построено.\nВторая строка.").ToJson();
Assert.DoesNotContain('\n', json);
Assert.DoesNotContain('\r', json);
}
[Fact]
public void Keeps_core_fields()
{
var root = Json(Entry(result: "Готово."));
Assert.Equal("2026-07-31T10:20:30.456Z", root.GetProperty("ts").GetString());
Assert.Equal("extrude", root.GetProperty("tool").GetString());
Assert.Equal(412, root.GetProperty("ms").GetInt32());
Assert.True(root.GetProperty("ok").GetBoolean());
Assert.Equal("Готово.", root.GetProperty("result").GetString());
}
[Fact]
public void Cyrillic_is_not_escaped()
=> Assert.Contains("Готово", Entry(result: "Готово.").ToJson());
[Fact]
public void Arguments_are_kept_as_json()
{
var root = Json(Entry(arguments: """{"depth":20,"mode":"boss","draft":false}"""));
var args = root.GetProperty("args");
Assert.Equal(20, args.GetProperty("depth").GetInt32());
Assert.Equal("boss", args.GetProperty("mode").GetString());
Assert.False(args.GetProperty("draft").GetBoolean());
}
[Fact]
public void Empty_arguments_are_omitted()
=> Assert.False(Json(Entry(arguments: "{}")).TryGetProperty("args", out _));
[Fact]
public void Huge_arguments_are_truncated_to_string()
{
var text = new string('ш', OperationLogEntry.ArgumentsLimit + 100);
var arguments = JsonSerializer.Serialize(new Dictionary<string, string> { ["text"] = text });
var args = Json(Entry(arguments: arguments)).GetProperty("args");
Assert.Equal(JsonValueKind.String, args.ValueKind);
Assert.Contains("симв.]", args.GetString());
}
[Fact]
public void Long_result_is_truncated_but_length_is_kept()
{
var text = new string('x', OperationLogEntry.ResultLimit + 250);
var root = Json(Entry(result: text));
Assert.StartsWith(new string('x', OperationLogEntry.ResultLimit), root.GetProperty("result").GetString());
Assert.Contains("[+250 симв.]", root.GetProperty("result").GetString());
Assert.Equal(text.Length, root.GetProperty("resultChars").GetInt32());
}
[Fact]
public void Error_is_kept_whole_and_replaces_result()
{
var message = "Инструмент «extrude» не выполнен: " + new string('п', OperationLogEntry.ResultLimit + 50);
var root = Json(Entry(ok: false, result: "не должно попасть", error: message));
Assert.False(root.GetProperty("ok").GetBoolean());
Assert.Equal(message, root.GetProperty("error").GetString());
Assert.False(root.TryGetProperty("result", out _));
}
[Fact]
public void Session_and_client_are_optional()
{
var root = Json(Entry());
Assert.False(root.TryGetProperty("session", out _));
Assert.False(root.TryGetProperty("client", out _));
}
[Fact]
public void Truncate_leaves_short_text_alone()
=> Assert.Equal("коротко", OperationLogEntry.Truncate("коротко", 100));
}
@@ -0,0 +1,74 @@
using Kompas.Mcp.Core.Diagnostics;
namespace Kompas.Mcp.Tests;
/// <summary>Настройки журнала операций: без пути — выключен, путь-каталог получает имя файла.</summary>
[Trait("Category", "Unit")]
public sealed class OperationLogSettingsTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Disabled_without_path(string? path)
{
var options = OperationLogSettings.Parse(enabled: "1", path: path);
Assert.False(options.Enabled);
Assert.Null(options.Path);
}
[Theory]
[InlineData("1")]
[InlineData("")]
[InlineData(null)]
[InlineData("что угодно")]
public void Enabled_when_path_set(string? enabled)
=> Assert.True(OperationLogSettings.Parse(enabled, Path.Combine("C:", "logs", "ops.jsonl")).Enabled);
[Theory]
[InlineData("0")]
[InlineData("false")]
[InlineData("OFF")]
[InlineData("no")]
[InlineData("disabled")]
[InlineData(" 0 ")]
public void Switch_off_keeps_path_but_disables(string enabled)
{
var file = Path.Combine("C:", "logs", "ops.jsonl");
var options = OperationLogSettings.Parse(enabled, file);
Assert.False(options.Enabled);
Assert.Equal(file, options.Path);
}
[Fact]
public void File_path_is_kept_as_is()
{
var file = Path.Combine("C:", "logs", "ops.jsonl");
Assert.Equal(file, OperationLogSettings.ResolveFile(file));
}
[Fact]
public void Directory_path_gets_default_file_name()
{
var directory = Path.Combine("C:", "logs");
Assert.Equal(Path.Combine(directory, OperationLogSettings.DefaultFileName),
OperationLogSettings.ResolveFile(directory));
}
[Fact]
public void Trailing_separator_means_directory()
{
var directory = Path.Combine("C:", "logs") + Path.DirectorySeparatorChar;
Assert.Equal(Path.Combine(directory, OperationLogSettings.DefaultFileName),
OperationLogSettings.ResolveFile(directory));
}
[Fact]
public void Path_is_trimmed()
=> Assert.Equal("ops.jsonl", OperationLogSettings.ResolveFile(" ops.jsonl "));
}
+164
View File
@@ -0,0 +1,164 @@
using System.Text.Json;
using Kompas.Mcp.Core.Diagnostics;
namespace Kompas.Mcp.Tests;
/// <summary>Журнал операций: пишет JSONL только при включённом режиме и не роняет вызовы при сбоях.</summary>
[Trait("Category", "Unit")]
public sealed class OperationLogTests : IDisposable
{
private readonly string _directory =
Path.Combine(Path.GetTempPath(), "kompas-mcp-oplog-" + Guid.NewGuid().ToString("N")[..8]);
private string File(string name = "ops.jsonl") => Path.Combine(_directory, name);
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
private static OperationLogEntry Entry(string tool = "extrude", bool ok = true, string? result = "Готово.")
=> new()
{
Timestamp = DateTimeOffset.UtcNow,
Tool = tool,
Duration = TimeSpan.FromMilliseconds(10),
Ok = ok,
Result = result,
};
[Fact]
public void Disabled_by_default()
{
var log = new OperationLog(new OperationLogOptions(Enabled: false, Path: File()));
log.Write(Entry());
Assert.False(log.Enabled);
Assert.False(System.IO.File.Exists(File()));
}
[Fact]
public void Writes_one_line_per_call()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
log.Write(Entry("extrude"));
log.Write(Entry("fillet_edge"));
var lines = System.IO.File.ReadAllLines(File());
Assert.Equal(2, lines.Length);
Assert.Equal("extrude", JsonDocument.Parse(lines[0]).RootElement.GetProperty("tool").GetString());
Assert.Equal("fillet_edge", JsonDocument.Parse(lines[1]).RootElement.GetProperty("tool").GetString());
Assert.Equal(2, log.WrittenCount);
}
[Fact]
public void Creates_missing_directory()
{
var nested = Path.Combine(_directory, "deep", "ops.jsonl");
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: nested));
log.Write(Entry());
Assert.True(System.IO.File.Exists(nested));
Assert.Equal(Path.GetFullPath(nested), log.Path);
}
[Fact]
public void Appends_to_existing_file()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
log.Write(Entry("extrude"));
var second = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
second.Write(Entry("hole"));
Assert.Equal(2, System.IO.File.ReadAllLines(File()).Length);
}
[Fact]
public void Disable_stops_writing_and_keeps_path()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
log.Write(Entry());
log.Disable();
log.Write(Entry());
Assert.Single(System.IO.File.ReadAllLines(File()));
Assert.False(log.Enabled);
Assert.Equal(Path.GetFullPath(File()), log.Path);
}
[Fact]
public void Enable_reuses_remembered_path()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
log.Disable();
var target = log.Enable();
log.Write(Entry());
Assert.Equal(Path.GetFullPath(File()), target);
Assert.Single(System.IO.File.ReadAllLines(File()));
}
[Fact]
public void Enable_switches_file()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
log.Write(Entry());
log.Enable(File("other.jsonl"));
log.Write(Entry());
Assert.Single(System.IO.File.ReadAllLines(File()));
Assert.Single(System.IO.File.ReadAllLines(File("other.jsonl")));
}
[Fact]
public void Enable_without_any_path_explains_itself()
{
var log = new OperationLog(new OperationLogOptions(Enabled: false, Path: null));
var ex = Assert.Throws<ArgumentException>(() => log.Enable());
Assert.Contains(OperationLogSettings.PathVariable, ex.Message);
Assert.False(log.Enabled);
}
[Fact]
public void Bad_path_disables_log_instead_of_throwing()
{
// Каталог на месте файла: запись невозможна, но конструктор обязан отдать выключенный журнал.
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: Path.Combine(_directory, "x") + Path.DirectorySeparatorChar));
Assert.False(log.Enabled);
Assert.NotNull(log.LastError);
}
[Fact]
public void Write_failure_is_swallowed_and_remembered()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
System.IO.File.Delete(File());
Directory.CreateDirectory(File()); // файл занят каталогом — AppendAllText упадёт
log.Write(Entry());
Assert.NotNull(log.LastError);
Assert.Equal(0, log.WrittenCount);
}
[Fact]
public void Describe_mentions_state()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
Assert.Contains(File(), log.Describe());
log.Disable();
Assert.Contains("выключен", log.Describe());
}
}
+65
View File
@@ -0,0 +1,65 @@
using Kompas.Mcp.Core.Documents;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Подготовка пути записи. КОМПАС в несуществующую папку молча не пишет, а инструмент рапортовал
/// «Сохранено» либо винил занятое имя файла — агент уходил чинить не ту причину.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OutputPathTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "kompas-mcp-tests", Guid.NewGuid().ToString("N")[..8]);
[Fact]
public void Missing_directory_is_created()
{
var path = Path.Combine(_root, "вложенная папка", "деталь.m3d");
var full = OutputPath.Prepare(path);
Assert.Equal(path, full);
Assert.True(Directory.Exists(Path.GetDirectoryName(full)), "каталог назначения не создан");
Assert.False(File.Exists(full), "сам файл создавать не нужно — его пишет КОМПАС");
}
[Fact]
public void Relative_path_becomes_absolute()
{
var full = OutputPath.Prepare("деталь.m3d");
Assert.True(Path.IsPathRooted(full), "относительный путь КОМПАС трактует от своего каталога");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Empty_path_is_rejected(string? path)
=> Assert.Throws<ArgumentException>(() => OutputPath.Prepare(path));
[Fact]
public void Directory_without_a_file_name_is_rejected()
{
var ex = Assert.Throws<ArgumentException>(() => OutputPath.Prepare(_root + Path.DirectorySeparatorChar));
Assert.Contains("каталог", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Existing_directory_is_left_alone()
{
Directory.CreateDirectory(_root);
var marker = Path.Combine(_root, "уже был.txt");
File.WriteAllText(marker, "x");
OutputPath.Prepare(Path.Combine(_root, "деталь.m3d"));
Assert.True(File.Exists(marker), "подготовка пути не должна трогать содержимое каталога");
}
public void Dispose()
{
try { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); }
catch (IOException) { /* временный каталог подчистит система */ }
}
}
@@ -0,0 +1,43 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Разбор списка точек: рёбра выбираются пакетом, чтобы N углов давали ОДНУ операцию в дереве.
/// Ошибка обязана называть позицию в пакете — иначе агент гадает, какая из точек кривая.
/// </summary>
[Trait("Category", "Unit")]
public sealed class Point3dInputsTests
{
[Fact]
public void Points_are_parsed_in_order()
{
var parsed = Point3dInputs.Parse(
[
new Point3dInput { X = 1, Y = 2, Z = 3 },
new Point3dInput { X = 4, Y = 5, Z = 6 },
]);
Assert.Equal(new Point3d(1, 2, 3), parsed[0]);
Assert.Equal(new Point3d(4, 5, 6), parsed[1]);
}
[Fact]
public void Absent_list_is_not_an_error_but_an_empty_result()
{
Assert.Empty(Point3dInputs.Parse(null));
Assert.Empty(Point3dInputs.Parse([]));
}
[Fact]
public void Non_finite_coordinate_names_its_position()
{
var ex = Assert.Throws<ArgumentException>(() => Point3dInputs.Parse(
[
new Point3dInput { X = 0, Y = 0, Z = 0 },
new Point3dInput { X = double.NaN, Y = 0, Z = 0 },
]));
Assert.Contains("[1]", ex.Message, StringComparison.Ordinal);
}
}
+77
View File
@@ -0,0 +1,77 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Разбор и валидация примитивов. У слитого инструмента схема не запрещает лишние поля,
/// поэтому недостающий размер обязана назвать валидация — вместе с типом тела.
/// </summary>
[Trait("Category", "Unit")]
public sealed class PrimitivesTests
{
[Theory]
[InlineData("block", PrimitiveKind.Block)]
[InlineData(" BOX ", PrimitiveKind.Block)]
[InlineData("cylinder", PrimitiveKind.Cylinder)]
[InlineData("sphere", PrimitiveKind.Sphere)]
[InlineData("cone", PrimitiveKind.Cone)]
public void Kind_names_and_aliases_are_recognized(string value, PrimitiveKind expected)
=> Assert.Equal(expected, Primitives.ParseKind(value));
[Theory]
[InlineData("", PrimitiveResult.NewBody)]
[InlineData("new", PrimitiveResult.NewBody)]
[InlineData("union", PrimitiveResult.Union)]
[InlineData("subtract", PrimitiveResult.Subtract)]
[InlineData("cut", PrimitiveResult.Subtract)]
[InlineData("intersect", PrimitiveResult.Intersect)]
public void Result_names_and_aliases_are_recognized(string value, PrimitiveResult expected)
=> Assert.Equal(expected, Primitives.ParseResult(value));
[Fact]
public void Unknown_kind_lists_the_allowed_ones()
{
var ex = Assert.Throws<ArgumentException>(() => Primitives.ParseKind("torus"));
Assert.Contains("torus", ex.Message, StringComparison.Ordinal);
Assert.Contains("cylinder", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Block_names_the_missing_dimension()
{
var ex = Assert.Throws<ArgumentException>(
() => Primitives.Validate(PrimitiveKind.Block, length: 10, width: null, height: 5, diameter: null));
Assert.Contains("block", ex.Message, StringComparison.Ordinal);
Assert.Contains("width", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Cylinder_needs_diameter_and_height_not_length()
{
var ex = Assert.Throws<ArgumentException>(
() => Primitives.Validate(PrimitiveKind.Cylinder, length: 10, width: 10, height: 5, diameter: null));
Assert.Contains("diameter", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Sphere_needs_only_the_diameter()
{
Primitives.Validate(PrimitiveKind.Sphere, length: null, width: null, height: null, diameter: 12);
}
[Fact]
public void Non_positive_size_is_rejected_with_the_value()
{
var ex = Assert.Throws<ArgumentException>(
() => Primitives.Validate(PrimitiveKind.Block, length: 10, width: 0, height: 5, diameter: null));
Assert.Contains("width", ex.Message, StringComparison.Ordinal);
Assert.Contains("> 0", ex.Message, StringComparison.Ordinal);
}
// Маппинг в COM-перечисления (ksObj3dTypeEnum / ksOperationResultEnum) проверяется
// интеграционно — сборки КОМПАС в тестовом проекте недоступны без установленного CAD.
}
@@ -102,6 +102,42 @@ public sealed class SketchEntityInputsTests
Assert.Equal(0, parsed.Angle);
}
[Fact]
public void Text_requires_insertion_point_string_and_height()
{
var noPoint = new SketchEntityInput { Type = "text", Text = "OldMan", Height = 10 };
Assert.Contains("points", Assert.Throws<ArgumentException>(
() => SketchEntityInputs.Parse([noPoint])).Message, StringComparison.Ordinal);
var noText = new SketchEntityInput { Type = "text", Points = [new SketchPoint(0, 0)], Height = 10 };
Assert.Contains("text", Assert.Throws<ArgumentException>(
() => SketchEntityInputs.Parse([noText])).Message, StringComparison.Ordinal);
var noHeight = new SketchEntityInput { Type = "text", Points = [new SketchPoint(0, 0)], Text = "OldMan" };
Assert.Contains("height", Assert.Throws<ArgumentException>(
() => SketchEntityInputs.Parse([noHeight])).Message, StringComparison.Ordinal);
}
[Fact]
public void Text_defaults_keep_normal_width_and_upright_face()
{
var e = new SketchEntityInput
{
Type = "text", Points = [new SketchPoint(4, 12)], Text = "EDITION", Height = 6, FontName = "Zilla Slab",
};
var parsed = SketchEntityInputs.Parse([e])[0];
Assert.Equal(SketchEntityKind.Text, parsed.Kind);
Assert.Equal("EDITION", parsed.Text);
Assert.Equal("Zilla Slab", parsed.FontName);
Assert.Equal(6, parsed.Height);
Assert.Equal(1.0, parsed.WidthFactor);
Assert.False(parsed.Bold);
Assert.False(parsed.Italic);
Assert.Equal((4.0, 12.0), parsed.Points[0]);
}
[Fact]
public void Points_keep_their_order()
{
+71
View File
@@ -0,0 +1,71 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Выравнивание надписи. КОМПАС кладёт строку только вправо-вверх от точки вставки, поэтому
/// «по центру» — это пересчёт по фактическому габариту глифов; раньше его делал агент отдельным
/// кругом «вставил → замерил → пересобрал».
/// </summary>
[Trait("Category", "Unit")]
public sealed class TextAlignsTests
{
[Theory]
[InlineData(null, TextAlign.Left)]
[InlineData("", TextAlign.Left)]
[InlineData("left", TextAlign.Left)]
[InlineData("CENTER", TextAlign.Center)]
[InlineData(" centre ", TextAlign.Center)]
[InlineData("right", TextAlign.Right)]
public void Horizontal_alignment_is_parsed(string? value, TextAlign expected)
=> Assert.Equal(expected, TextAligns.Parse(value));
[Theory]
[InlineData(null, TextVAlign.Baseline)]
[InlineData("bottom", TextVAlign.Bottom)]
[InlineData("middle", TextVAlign.Middle)]
[InlineData("top", TextVAlign.Top)]
public void Vertical_alignment_is_parsed(string? value, TextVAlign expected)
=> Assert.Equal(expected, TextAligns.ParseVertical(value));
[Fact]
public void Unknown_alignment_lists_the_allowed_values()
{
var ex = Assert.Throws<ArgumentException>(() => TextAligns.Parse("justify"));
Assert.Contains("left", ex.Message, StringComparison.Ordinal);
Assert.Contains("center", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Centering_moves_the_box_middle_onto_the_anchor()
{
// Глифы легли на 10…30 (середина 20), якорь — 0: сдвиг −20.
var (dx, dy) = TextAligns.Offset((10, 0, 30, 12), (0, 0), TextAlign.Center, TextVAlign.Baseline);
Assert.Equal(-20, dx, 9);
Assert.Equal(0, dy, 9);
}
[Fact]
public void Right_alignment_puts_the_box_end_onto_the_anchor()
{
var (dx, _) = TextAligns.Offset((10, 0, 30, 12), (90, 0), TextAlign.Right, TextVAlign.Baseline);
Assert.Equal(60, dx, 9); // 90 30
}
[Fact]
public void Vertical_middle_uses_the_glyph_box_not_the_baseline()
{
// Выносные ушли ниже базовой линии: габарит −3…12, середина 4.5.
var (_, dy) = TextAligns.Offset((0, -3, 20, 12), (0, 0), TextAlign.Left, TextVAlign.Middle);
Assert.Equal(-4.5, dy, 9);
}
[Fact]
public void Left_baseline_is_a_no_op()
{
var (dx, dy) = TextAligns.Offset((10, -3, 30, 12), (5, 7), TextAlign.Left, TextVAlign.Baseline);
Assert.Equal(0, dx, 9);
Assert.Equal(0, dy, 9);
}
}
@@ -0,0 +1,45 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Ответ по надписи. Агент вписывает текст в заданное поле, поэтому ему нужен габарит ГЛИФОВ;
/// ширина ячейки (ksGetTextLengthFromReference) больше него на 1–2 % у наборных шрифтов и на
/// 7–9 % у скриптовых — подстановка одного вместо другого промахивается мимо поля.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TextMetricsTests
{
[Fact]
public void Box_and_cell_are_both_reported()
{
var m = new TextMetrics(94.57, (1.0, 12.14, 87.93, 28.94));
var text = m.Describe("OldMan");
Assert.Contains("OldMan", text, StringComparison.Ordinal);
Assert.Contains("1.00…87.93", text, StringComparison.Ordinal); // габарит по X
Assert.Contains("12.14…28.94", text, StringComparison.Ordinal); // габарит по Y
Assert.Contains("86.93 × 16.80", text, StringComparison.Ordinal); // ширина × высота глифов
Assert.Contains("94.57", text, StringComparison.Ordinal); // ячейка — отдельно
}
[Fact]
public void Missing_box_is_stated_instead_of_being_faked()
{
var text = new TextMetrics(50.0, null).Describe("EDITION");
Assert.Contains("50.00", text, StringComparison.Ordinal);
Assert.Contains("не отдал", text, StringComparison.Ordinal);
}
[Fact]
public void Decimal_separator_does_not_depend_on_the_machine_locale()
{
var text = new TextMetrics(1.5, (0, 0, 1, 2)).Describe("x");
// Запятая в строке есть — она разделяет оси; проверяем именно дробную часть числа.
Assert.Contains("1.50", text, StringComparison.Ordinal);
Assert.DoesNotContain("1,50", text, StringComparison.Ordinal);
}
}
+29
View File
@@ -0,0 +1,29 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>Разбор стороны тонкой стенки: значение приходит строкой из схемы инструмента.</summary>
[Trait("Category", "Unit")]
public sealed class ThinSidesTests
{
[Theory]
[InlineData("outward", ThinSide.Outward)]
[InlineData(" OUT ", ThinSide.Outward)]
[InlineData("", ThinSide.Outward)]
[InlineData(null, ThinSide.Outward)]
[InlineData("inward", ThinSide.Inward)]
[InlineData("in", ThinSide.Inward)]
[InlineData("both", ThinSide.Both)]
[InlineData("middle", ThinSide.Both)]
public void Known_values_and_aliases_are_recognized(string? value, ThinSide expected)
=> Assert.Equal(expected, ThinSides.Parse(value));
[Fact]
public void Unknown_value_lists_the_allowed_ones()
{
var ex = Assert.Throws<ArgumentException>(() => ThinSides.Parse("sideways"));
Assert.Contains("sideways", ex.Message, StringComparison.Ordinal);
Assert.Contains("outward", ex.Message, StringComparison.Ordinal);
}
}
@@ -0,0 +1,60 @@
using Kompas.Mcp.Core.Startup;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Текст ошибки инструмента. SDK показывает клиенту сообщение только у McpException, поэтому
/// причину собирает фильтр — и она обязана называть инструмент и суть, а не тип исключения.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ToolErrorTextTests
{
[Fact]
public void Message_names_the_tool_and_the_reason()
{
var text = ToolErrorText.Describe("extrude", new InvalidOperationException("Эскиз id=7 уже закрыт."));
Assert.Contains("extrude", text, StringComparison.Ordinal);
Assert.Contains("Эскиз id=7 уже закрыт.", text, StringComparison.Ordinal);
}
[Fact]
public void Inner_exception_is_appended_as_the_underlying_cause()
{
var inner = new InvalidOperationException("ksTextEx вернул 0.");
var outer = new InvalidOperationException("Примитив [0] (text) не построен.", inner);
var text = ToolErrorText.Describe("sketch_add", outer);
Assert.Contains("Примитив [0] (text) не построен.", text, StringComparison.Ordinal);
Assert.Contains("ksTextEx вернул 0.", text, StringComparison.Ordinal);
}
[Fact]
public void Single_wrapped_aggregate_is_unwrapped_to_the_real_cause()
{
var aggregate = new AggregateException(new KeyNotFoundException("Эскиз с id=42 не найден."));
var text = ToolErrorText.Describe("extrude", aggregate);
Assert.Contains("Эскиз с id=42 не найден.", text, StringComparison.Ordinal);
Assert.DoesNotContain("One or more errors", text, StringComparison.Ordinal);
}
[Fact]
public void Empty_message_falls_back_to_the_exception_type()
{
var text = ToolErrorText.Describe("hole", new TimeoutException(""));
Assert.Contains("TimeoutException", text, StringComparison.Ordinal);
}
[Fact]
public void Missing_tool_name_still_yields_a_readable_message()
{
var text = ToolErrorText.Describe(null, new ArgumentException("Радиус должен быть > 0."));
Assert.Contains("Радиус должен быть > 0.", text, StringComparison.Ordinal);
Assert.DoesNotContain("«»", text, StringComparison.Ordinal);
}
}
+118
View File
@@ -0,0 +1,118 @@
#requires -Version 7.0
<#
.SYNOPSIS
Сводка по журналу операций MCP-сервера (JSONL): чем пользуются, что падает, что тормозит.
.DESCRIPTION
Журнал накапливает вызовы инструментов (см. set_operation_log и KOMPAS_MCP_OPLOG_PATH). Этот
отчёт — то, ради чего он ведётся: по нему видно, какие инструменты каталога реально зовут,
какие стабильно отказывают (кандидаты на доработку) и какие не зовут никогда (кандидаты на
удаление). Инструменты, ни разу не встретившиеся в журнале, перечисляются отдельно — их список
берётся из README (каталог инструментов там источник истины).
.EXAMPLE
pwsh -NoProfile -File tools/dev/oplog-report.ps1
.EXAMPLE
pwsh -NoProfile -File tools/dev/oplog-report.ps1 -Path D:\logs\kompas-operations.jsonl -Errors 5
#>
[CmdletBinding()]
param(
# Файл журнала или каталог с ним. По умолчанию — KOMPAS_MCP_OPLOG_PATH.
[string] $Path = $env:KOMPAS_MCP_OPLOG_PATH,
# Сколько текстов ошибок показать в разделе «частые отказы».
[int] $Errors = 10
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($Path)) {
throw "Путь к журналу не задан: укажите -Path или переменную окружения KOMPAS_MCP_OPLOG_PATH."
}
if (Test-Path -LiteralPath $Path -PathType Container) {
$Path = Join-Path $Path 'kompas-operations.jsonl'
}
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Журнал не найден: $Path"
}
# Битая строка (сервер убит посреди записи) не должна ронять отчёт.
$broken = 0
$records = foreach ($line in (Get-Content -LiteralPath $Path -Encoding utf8)) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
try { $line | ConvertFrom-Json } catch { $broken++ }
}
$records = @($records)
if ($records.Count -eq 0) {
Write-Host "Журнал пуст: $Path"
return
}
$sessions = @($records | Where-Object { $_.PSObject.Properties.Name -contains 'session' } |
Select-Object -ExpandProperty session -Unique)
$failed = @($records | Where-Object { -not $_.ok })
Write-Host "Журнал: $Path"
Write-Host ("Вызовов: {0} отказов: {1} ({2:P1}) период: {3} … {4}" -f
$records.Count, $failed.Count, ($failed.Count / $records.Count),
$records[0].ts, $records[-1].ts)
if ($sessions.Count -gt 0) { Write-Host "Сессий: $($sessions.Count)" }
if ($broken -gt 0) { Write-Host "Нечитаемых строк: $broken" -ForegroundColor DarkYellow }
Write-Host "`n=== Инструменты ==="
$byTool = $records | Group-Object tool | ForEach-Object {
$calls = @($_.Group)
$bad = @($calls | Where-Object { -not $_.ok })
$ms = @($calls | ForEach-Object { [double] $_.ms } | Sort-Object)
[pscustomobject]@{
Инструмент = $_.Name
Вызовов = $calls.Count
Отказов = $bad.Count
'Отказы %' = [int] [math]::Round(100 * $bad.Count / $calls.Count)
'мс med' = [int] $ms[[math]::Floor($ms.Count / 2)]
'мс max' = [int] ($ms | Select-Object -Last 1)
}
}
$byTool | Sort-Object Вызовов -Descending | Format-Table -AutoSize
$suspects = @($byTool | Where-Object { $_.Отказов -gt 0 } | Sort-Object 'Отказы %' -Descending)
if ($suspects.Count -gt 0) {
Write-Host "=== Кандидаты на доработку (есть отказы) ==="
$suspects | Format-Table -AutoSize
}
if ($failed.Count -gt 0) {
Write-Host "=== Частые отказы ==="
$failed |
Group-Object { "$($_.tool): " + ($_.error -replace '\s+', ' ') } |
Sort-Object Count -Descending |
Select-Object -First $Errors |
ForEach-Object {
$text = $_.Name
if ($text.Length -gt 160) { $text = $text.Substring(0, 160) + '…' }
Write-Host ("{0,4}x {1}" -f $_.Count, $text)
}
}
# Не звали ни разу. Каталог берётся из исходников (атрибуты McpServerTool), а не из README:
# имя инструмента там объявлено буквально, без риска поймать в шаблон параметр или слово из текста.
$toolsDir = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'src\Kompas.Mcp.Host\Tools'
if (Test-Path -LiteralPath $toolsDir) {
$catalog = [System.Collections.Generic.HashSet[string]]::new()
foreach ($file in (Get-ChildItem -LiteralPath $toolsDir -Filter '*.cs')) {
$text = Get-Content -LiteralPath $file.FullName -Raw -Encoding utf8
foreach ($m in [regex]::Matches($text, 'McpServerTool\(Name\s*=\s*"([^"]+)"')) {
[void] $catalog.Add($m.Groups[1].Value)
}
}
$known = $catalog.Count
foreach ($tool in ($records | Select-Object -ExpandProperty tool -Unique)) { [void] $catalog.Remove($tool) }
Write-Host "`n=== Ни разу не встречались в журнале: $($catalog.Count) из $known ==="
if ($catalog.Count -gt 0) { Write-Host (($catalog | Sort-Object) -join ', ') }
}
+3 -1
View File
@@ -67,7 +67,9 @@ if ($Check) {
exit $LASTEXITCODE
}
$blocking = Get-BlockingProcess -Directory $binDir
# @() обязательно: единственный найденный процесс вернулся бы скаляром, и .Count под
# Set-StrictMode падает — та же ловушка, что в лаунчере с Get-ChildItem.
$blocking = @(Get-BlockingProcess -Directory $binDir)
if ($blocking.Count -gt 0) {
Write-Host 'Сборка невозможна: bin занят живыми сессиями MCP.' -ForegroundColor Red
foreach ($p in $blocking) {