Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69a075c890 | |||
| 6b4563a053 | |||
| e127cebb49 | |||
| 5ef452ceb7 | |||
| 31b933e1cc | |||
| 461e731857 | |||
| 814c9cf7b1 | |||
| d0c9d8ca3f | |||
| 4f4dd082d9 | |||
| a4cc755fb4 | |||
| dfe5636290 |
@@ -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 при выполненной непростой задаче это
|
||||
сильный сигнал, что навык и каталог инструментов дозрели; выдумывать замечания ради заполнения
|
||||
формы не надо.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-тестов не входят.
|
||||
|
||||
@@ -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) и на 7–9 % у
|
||||
скриптовых (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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 — нормальная ширина, <1 — уже, >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}."),
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 '<tool>'» —
|
||||
/// а для агента ценна как раз причина: какого параметра не хватило и что делать дальше.
|
||||
/// Форматирование живёт в 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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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("Тела объединены.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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 "));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 ', ') }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user