Каталог инструментов сжат под агента: 84 → 56
ci / build (push) Successful in 28s

Инструменты были нарезаны по способу вызова, а не по смыслу: четыре отверстия,
двойники *_index, одиннадцать sketch_add_*. Агент платил за это дважды — 40 КБ
описаний в каждой сессии и лишние round-trip'ы, а каждый вызов это ещё и шанс
сбиться. Теперь инструмент называет ОПЕРАЦИЮ, вариант задаётся параметром,
объект выбирается индексом или точкой одним и тем же инструментом.

Проверка построения приходит сама. Каждая мутирующая операция дописывает к ответу
итог validate_part (AutoValidation): Create()/Update()==true не значит успех, а
правило навыка «проверяй после каждого шага» удваивало число вызовов. Выключается
через set_auto_validate или KOMPAS_MCP_AUTOVALIDATE=0 — сбой самой проверки уходит
в примечание и никогда не превращает удачную операцию в ошибку.

Эскиз: 16 инструментов → 3. sketch_create(plane|faceIndex|x,y,z, entities[],
autoClose) строит контур целиком; пакет выполняется за ОДИН заход на STA-поток
(PartModeler.AddEntitiesAsync), ошибка называет позицию примитива в списке.

Слияния: hole(type=simple|counterbore|countersink|conic), extrude/revolve(mode),
pattern(kind), mirror (без featureIds — всё тело), document_save(path?),
set_variable как upsert (разведочный вызов «есть ли такая» больше не нужен),
list_faces/list_edges(index?) вместо отдельных describe_*, get_part_info и
get_bounding_box — в describe_model(sections), где незапрошенные разделы вообще
не читаются из модели.

Селектор index|point: fillet_edge/chamfer_edge принимают edgeIndices списком —
одна операция дерева на все рёбра; для операций API7, умеющих только точку,
индекс переводится в точку через ModelInspectionService.FaceCenterPointAsync
(середина параметрической области грани).

Схема слитого инструмента не запрещает неверную комбинацию полей — это делает
валидация, и её сообщение называет type и недостающий параметр.

Тесты: 291 unit (+41) и 136 integration (+5), интеграционные — на живом КОМПАС.
Новые проверяют ровно рискованные места: точка-из-индекса лежит на грани и по ней
создаётся эскиз, мульти-ребёрное скругление даёт один узел дерева и убыль объёма,
upsert создаёт и затем меняет переменную с формулой, пакет сообщает позицию сбоя.
PluginSkillsTests теперь падает, если в публикуемом навыке всплывёт слитое имя.
This commit is contained in:
2026-07-31 11:52:30 +03:00
parent cb43b84249
commit 4e3d367637
32 changed files with 1747 additions and 660 deletions
+22
View File
@@ -28,6 +28,28 @@ description: Внутренняя методика доработки самог
Итог: **MCP = чем делать, навык = как делать.** Новый приём сначала обкатывается кейсом, потом
обобщается: инструмент → в MCP, метод → в навык `kompas-3d`.
## Правила формы инструмента (каталог оптимизирован под агента)
Каталог сознательно сжат с 84 инструментов до 56 — экономится не только контекст, но и число
вызовов, а каждый лишний вызов это лишний шанс сбиться. Держи форму:
- **Инструмент = смысл операции, вариант = параметр.** `extrude(mode=boss|cut)`,
`hole(type=simple|counterbore|…)`, `pattern(kind=linear|circular)`. Отдельный инструмент заводи,
только если у варианта своя схема параметров (`sweep`, `loft`, `rib` — законно отдельные).
- **Объект выбирается индексом ИЛИ точкой одним инструментом.** Не плоди `*_index`-двойники:
`faceIndex`/`edgeIndices` из `list_faces`/`list_edges` — основной путь, `x,y,z` — запасной.
У операций API7, которые умеют только точку, индекс переводится в точку через
`ModelInspectionService.FaceCenterPointAsync` (середина параметрической области грани).
- **Пакет вместо серии вызовов.** Списочный параметр (`entities[]`, `edgeIndices[]`, `featureIds[]`)
выполняется за ОДИН заход на STA-поток: см. `PartModeler.AddEntitiesAsync` и синхронные ядра
примитивов рядом с ним. Ошибка обязана называть позицию в пакете.
- **Схема не запрещает неверную комбинацию полей — это делает валидация.** Сообщение называет
`type=…` и недостающий параметр (`SketchEntityInputs`, `FeatureTools.Required`), иначе агент
тратит лишний ход на догадки.
- **Мутирующая операция сама дописывает итог проверки** через `AutoValidation.AnnotateAsync`
(`Core/Validation`). Переключатель — `set_auto_validate` и `KOMPAS_MCP_AUTOVALIDATE`; проверка
никогда не превращает удачную операцию в ошибку (сбой самой проверки уходит в примечание).
## Продуктизация приёма
> Если нужного инструмента ещё нет — не хардкодь обход под кейс. Заведи кейс в `usecases/`,
@@ -102,21 +102,22 @@
`sketchShiftType=0` → `SetThinParam(false)` → `Create()`. Profile and path on different (usually ⟂)
planes; `profileSketchId != pathSketchId`.
- `loft` (`ksBaseLoftDefinition`, =30): `Sketchs().Add(each)` → `SetLoftParam(false,false,true)` →
`SetThinParam(false)`. ≥2 closed sections on parallel planes — use `sketch_create_on_offset_plane` for
`SetThinParam(false)`. ≥2 closed sections on parallel planes — use `sketch_create(plane, offset)` for
non-base sections.
- offset plane (`sketch_create_on_offset_plane`, `ksPlaneOffsetDefinition`, o3d_planeOffset=14):
- offset plane (`sketch_create` with `offset≠0`, `ksPlaneOffsetDefinition`, o3d_planeOffset=14):
`SetPlane(base)` + `offset` mm + `direction` → `Create()` → sketch on it (`OpenSketchOnOffsetPlaneAsync`).
- `draft` (`ksInclineDefinition`, o3d_incline=42 — API5 calls the op **Incline**, NOT Draft): `FaceArray()`
(by index) + `SetPlane(neutral base plane)` + `angle` (degrees) + `direction`. **Empirical, opposite to
the SDK docs:** `direction=false` = expanding/outward (adds material), `true` = tapering/inward → the code
maps `def.direction = !outward`. Validate `0<angle<90`. (`o3d_DraftFromEdges=644` / `IDraftFromEdges` is
a different op — not used.)
- **Patterns & mirror (API5)**: `linear_pattern` (`ksMeshCopyDefinition`, =35): `SetAxis1(axis)` +
- **Patterns & mirror (API5)** — one tool each, the variant is a parameter: `pattern(kind=linear)`
(`ksMeshCopyDefinition`, =35): `SetAxis1(axis)` +
`SetCopyParamAlongAxis(true,0,count,step,false)` + `count2=1` (disables the 2nd direction → 1D); features
via `OperationArray()`. `circular_pattern` (`ksCircularCopyDefinition`, =36): `SetAxis(axis)`; `count1=1`
via `OperationArray()`. `pattern(kind=circular)` (`ksCircularCopyDefinition`, =36): `SetAxis(axis)`; `count1=1`
(radial off), `count2=count` (ring), `step2=angle°` between neighbours, `factor2=false`, `inverce=reverse`;
`GetOperationArray().Add(feature)`. `mirror_operation` (`ksMirrorCopyDefinition`, =48): `SetPlane(base)` +
`GetOperationArray().Add(feature)`. `mirror_body` (`ksMirrorCopyAllDefinition`, =49): `SetPlane(base)`;
`GetOperationArray().Add(feature)`. `mirror(featureIds)` (`ksMirrorCopyDefinition`, =48): `SetPlane(base)` +
`GetOperationArray().Add(feature)`. `mirror()` without ids (`ksMirrorCopyAllDefinition`, =49): `SetPlane(base)`;
`ChooseBodies()` casts to null in the interop and is NOT needed — it mirrors all bodies keeping the original
(volume doubles). Empirical: `count` includes the original instance (count=3 → 3 instances). Axes:
`CoordinateAxis{X,Y,Z}` → `o3d_axisOX/OY/OZ` (`Core/Modeling/CoordinateAxis.cs`, analogous to `BasePlane.cs`).
@@ -124,10 +125,11 @@
`HoleCore(ksHoleTypeEnum holeType, Action<IHole3D> configure)` — `configure` sets `HoleParameters` **after**
`HoleType` is assigned (the type-specific sub-interface is inaccessible until the type is fixed). Placement
is shared: `IModelContainer`/`IHole3D`/`IHoleDisposal` + `Points3D`, direction chosen by volume delta,
orphan rollback. None are registered in `_features` → no id returned. `hole`=`ksHTBase`, configure=null.
`hole_counterbore`=`ksHTCounterbore` + `(ISpotfacingHoleParameters)` (`SpotfacingDiameter`/`SpotfacingDepth`).
`hole_countersink`=`ksHTCountersinking` + `(ICountersinkHoleParameters)` (`CountersinkType=ksCTDiameterAngle`,
`CountersinkDiameter`/`Angle`). `hole_conic`=`ksHTConic` + `(IConicHoleParameters)` (`ConicType=ksCNAngle`,
orphan rollback. None are registered in `_features` → no id returned. One tool `hole`, the variant comes
from `type`: `simple`=`ksHTBase`, configure=null.
`counterbore`=`ksHTCounterbore` + `(ISpotfacingHoleParameters)` (`SpotfacingDiameter`/`SpotfacingDepth`).
`countersink`=`ksHTCountersinking` + `(ICountersinkHoleParameters)` (`CountersinkType=ksCTDiameterAngle`,
`CountersinkDiameter`/`Angle`). `conic`=`ksHTConic` + `(IConicHoleParameters)` (`ConicType=ksCNAngle`,
`ConicAngle`).
- **Assembly (API7, `AssemblyService`, namespace `Kompas.Mcp.Core.Assemblies`)**: `assembly_add_component` —
`IParts7.AddFromFile(path, ExternalFile=true, Redraw=true)` → `IPart7.Placement` (`IPlacement3D`) →
+32 -5
View File
@@ -39,6 +39,14 @@ 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
*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
result (`AutoValidation`, switched by `set_auto_validate` / `KOMPAS_MCP_AUTOVALIDATE`) so the agent doesn't
call `validate_part` after each step. Keep that shape when adding tools — rules and rationale in skill
`kompas-mcp-dev` §«Правила формы инструмента».
```powershell
dotnet build -c Release # build
dotnet test --filter "Category=Unit" # no COM needed
@@ -58,7 +66,8 @@ a *general* tool with tests, and describe the method in the `kompas-3d` skill.
- **Assembly**: mate types beyond `coincidence`/`distance``parallel`, `perpendicular`, `concentric`,
`angle`, `tangency` (enum values exist in `MateType.cs`, not verified live).
- **Known caveat:** boss/cut direction on a *selected face* depends on the face-normal orientation —
`forward` may need flipping. Pick the direction from `describe_face` normal or a snapshot before building.
`forward` may need flipping. Pick the direction from the `list_faces(index=…)` normal or a snapshot
before building.
## Working mode: CAD tasks are the proving ground
@@ -81,10 +90,28 @@ asking for a session restart when a rebuilt server has to be picked up.
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.
- **Rebuild locks the binary.** In a session where `.mcp.json` points `kompas` straight at
`src/Kompas.Mcp.Host/bin/x64/Release/net8.0-windows/kompas-mcp.exe`, the running server holds that file —
`dotnet build -c Release` fails with «file in use» until the process dies. So batch the changes:
accumulate patches → end the session → build → restart → re-run the scenario.
### The rebuild cycle
A running server holds its own binary: with `.mcp.json` pointing straight at
`src/Kompas.Mcp.Host/bin/…/kompas-mcp.exe`, `dotnet build -c Release` dies on `MSB3027` («file in use»),
and *every* open session counts — the error names each holding PID. `tools/dev/` removes that coupling.
- **`tools/dev/launch-dev-mcp.ps1`** — what `.mcp.json` now points at. It copies the build (~10 MB) into
`%LOCALAPPDATA%\kompas-mcp\dev\<fingerprint>` and runs the server from there, so `bin` is never locked and
a build never waits for a session to close. Logic lives in `tools/dev/KompasMcpDev.psm1`; overrides:
`KOMPAS_MCP_EXE` (bypass the shadow copy entirely), `KOMPAS_MCP_DEV_BUILD_DIR`, `KOMPAS_MCP_DEV_ROOT`.
- **`tools/dev/rebuild-mcp.ps1`** — `-Check` compiles past `bin` (redirect `BaseOutputPath` only — moving
`BaseIntermediateOutputPath` too makes the stale `obj/` visible to the glob and yields `CS0579`) so a
syntax/type check costs ~2 s with the server live. Without flags: full build + `Category=Unit`, refusing
early with the blocking PIDs instead of burning 15 s on retries.
- **A new binary still needs the stdio process restarted** — `/mcp` reconnect if the client supports it,
otherwise a new session. Restarting is safe for КОМПАС: `KompasSession.Dispose` releases the COM objects
but deliberately never calls `Quit`, so the document survives. What does not survive: the connection
(`kompas_connect` again) and the in-memory build state — sketches by `id`. Save before restarting.
- **Two traps in the launcher's PowerShell**, both invisible under `pwsh` 7 and fatal under the 5.1 that
`.mcp.json` actually uses: `Get-ChildItem` returning a *single* file is a scalar, so `.Count` throws under
`Set-StrictMode`; and `Sort-Object` orders punctuation differently in 5.1 (NLS) vs 7 (ICU), which made the
same build hash to two different fingerprints. Sort ordinal (`List.Sort([StringComparer]::Ordinal)`).
## Skills and their layout
+25 -13
View File
@@ -2,7 +2,7 @@
**MCP-сервер для управления CAD-системой КОМПАС-3D (АСКОН) языковой моделью.**
Сервер — COM-клиент КОМПАС-3D: подключается к установленному КОМПАС и даёт LLM 84 инструмента для
Сервер — COM-клиент КОМПАС-3D: подключается к установленному КОМПАС и даёт LLM 56 инструментов для
создания документов, построения 3D-деталей (эскиз → формообразующая операция), работы со сборками и
чертежами, импорта/экспорта STEP, прямого редактирования импортированной B-rep, а также структурного
осмотра модели (`describe_model` — дерево операций, тела, топология, переменные, МЦХ одним вызовом)
@@ -61,31 +61,43 @@ kompas-mcp.exe --version # версия бинаря, КОМПАС для эт
}
```
## Инструменты (84)
## Инструменты (56)
| Группа | Инструменты |
|---|---|
| System | `kompas_connect`, `kompas_status`, `kompas_set_visible` |
| Documents | `document_create`, `document_open`, `document_save`, `document_save_as`, `document_close`, `document_active` |
| Sketch | `sketch_create`, `sketch_create_on_face`, `sketch_create_on_face_index`, `sketch_create_on_offset_plane`, `sketch_add_line`, `sketch_add_circle`, `sketch_add_rectangle`, `sketch_add_axis`, `sketch_add_arc`, `sketch_add_arc_3points`, `sketch_add_ellipse`, `sketch_add_polyline`, `sketch_add_polygon`, `sketch_add_spline`, `sketch_add_point`, `sketch_close` |
| Features | `extrude_boss`, `extrude_cut`, `revolve_boss`, `revolve_cut`, `fillet_edge`, `chamfer_edge`, `fillet_edge_index`, `chamfer_edge_index`, `shell`, `rib`, `sweep`, `loft`, `linear_pattern`, `circular_pattern`, `mirror_operation`, `mirror_body`, `hole`, `hole_counterbore`, `hole_countersink`, `hole_conic`, `draft`, `rebuild` |
| System | `kompas_connect`, `kompas_status`, `kompas_set_visible`, `set_auto_validate` |
| Documents | `document_create`, `document_open`, `document_save`, `document_close`, `document_active` |
| Sketch | `sketch_create`, `sketch_add`, `sketch_close` |
| Features | `extrude`, `revolve`, `fillet_edge`, `chamfer_edge`, `shell`, `rib`, `sweep`, `loft`, `pattern`, `mirror`, `hole`, `draft`, `rebuild` |
| Edit | `move_face`, `split_solid_by_plane`, `move_body`, `boolean_union` |
| Inspection | `describe_model`, `list_features`, `list_bodies`, `list_variables`, `describe_face`, `describe_edge`, `measure` |
| Inspection | `describe_model`, `list_features`, `list_bodies`, `list_variables`, `measure` |
| Vision | `model_snapshot` (PNG-снимок; сначала — `describe_model`) |
| Query | `get_part_info`, `get_bounding_box`, `list_faces`, `list_edges`, `list_components` |
| Variables | `create_variable`, `set_variable`, `set_variable_note`, `delete_variable` |
| Query | `list_faces`, `list_edges`, `list_components` |
| Variables | `set_variable`, `delete_variable` |
| Conversion | `import_step`, `export_step` |
| Assembly | `assembly_add_component`, `assembly_add_mate` |
| Drawing | `drawing_create_standard_views`, `drawing_fill_title_block`, `drawing_add_linear_dimension`, `drawing_add_diametral_dimension`, `drawing_add_radial_dimension`, `drawing_add_angular_dimension`, `drawing_add_leader`, `drawing_add_rough`, `drawing_add_text`, `drawing_set_technical_requirements`, `drawing_set_sheet_format` |
| Validation | `validate_part` |
Инструменты сгруппированы по смыслу операции, а не по способу вызова: тип задаётся параметром
(`extrude(mode=boss|cut)`, `hole(type=simple|counterbore|countersink|conic)`,
`pattern(kind=linear|circular)`), а объект — либо индексом из `list_faces`/`list_edges`,
либо точкой в мировых координатах. Эскиз строится списком примитивов за один вызов:
`sketch_create(plane|faceIndex, entities[])`.
### Типовой сценарий
`kompas_connect``document_create part` `sketch_create XOY``sketch_add_circle`
`sketch_close``extrude_boss``describe_model` → (повтор для следующих элементов).
`kompas_connect``document_create part`
`sketch_create(plane="XOY", entities=[{type:"rectangle", points:[…]}, {type:"circle", …}])`
`extrude(mode="boss", depth=20)``describe_model` → (повтор для следующих элементов).
> `describe_model` предпочтителен перед `model_snapshot`: отдаёт структурный «паспорт» детали
> (дерево операций, тела, переменные, топологию, МЦХ) без расхода токенов на изображение.
> Параметр `sections` (`box,mass,…`) сужает и ответ, и объём чтения модели.
> Каждая мутирующая операция сама дописывает к ответу итог проверки построения — отдельный
> `validate_part` после каждого шага не нужен. Отключается на сессию через `set_auto_validate(false)`
> или при старте сервера переменной окружения `KOMPAS_MCP_AUTOVALIDATE=0`.
> ⚠️ Вызовы зависимых инструментов выполняйте **последовательно**, дождавшись ответа: сессия
> построения хранит эскизы по `id`, а сервер обрабатывает запросы конкурентно.
@@ -96,7 +108,7 @@ kompas-mcp.exe --version # версия бинаря, КОМПАС для эт
src/Kompas.Mcp.Core/ COM-слой: STA-диспетчер, подключение, документы, эскизы/операции,
сборки, чертежи, конвертация, снимок, инспекция модели
src/Kompas.Mcp.Host/ MCP-сервер (stdio) + определения инструментов (Tools/ — по группе на файл)
tests/Kompas.Mcp.Tests/ 381 тест: unit (корень) + Integration/ (нужен запущенный КОМПАС)
tests/Kompas.Mcp.Tests/ 427 тестов: unit (корень) + Integration/ (нужен запущенный КОМПАС)
libs/kompas-interop/ interop-сборки КОМПАС (из SDK Samples/Common) — только для компиляции
plugin/ плагин Claude Code `kompas`: манифест, лаунчер, лок версии сервера,
навыки kompas-3d и kompas-fdm-design (источник истины)
@@ -120,7 +132,7 @@ dotnet test --filter "Category=Integration" # только integration
pwsh -NoProfile -File tools/tests/run-ps-tests.ps1 # Pester-тесты скриптов плагина
```
381 тест: 250 unit + 131 integration. CI (Gitea Actions, `.gitea/workflows/ci.yml`) собирает проект и
427 тестов: 291 unit + 136 integration. CI (Gitea Actions, `.gitea/workflows/ci.yml`) собирает проект и
гоняет `Category=Unit&Requires!=Windows` на Linux-раннере (245 тестов) — 5 тестов STA-диспетчера
помечены `Requires=Windows` (`Thread.SetApartmentState` не работает на Linux). Integration-тесты в CI
не запускаются никогда: нужен запущенный КОМПАС с GUI и лицензией. Плюс 41 Pester-тест
+54 -40
View File
@@ -26,34 +26,39 @@ MCP-сервер даёт **общие** операции КОМПАС (эски
**1. «Зрение» — структурное, не по картинке.** Чтобы «увидеть» деталь, вызывай **`describe_model`** —
это единый структурный паспорт одним запросом: габарит, МЦХ, тела, топология (грани/рёбра, сгруппированные
по типам), дерево построения с параметрами, переменные. Это **точнее и дешевле по контексту**, чем
`model_snapshot`. Для деталей углубляйся `describe_face` / `describe_edge` / `measure` / `list_faces|edges|bodies|features|variables`.
`model_snapshot`. Для деталей углубляйся `list_faces(index=N)` / `list_edges(index=N)` / `measure` / `list_bodies|features|variables`.
**`model_snapshot` бери только** для визуально-пространственных вопросов, на которые паспорт не отвечает
(общая форма, ориентация, правдоподобность результата, «куда смотрит грань»). **Не анализируй изображение
там, где достаточно чисел** — снимок дорог по контексту и менее точен.
**2. `validate_part` — после КАЖДОЙ операции.** Любая формообразующая или прямое редактирование
(`extrude_*`/`revolve_*`/`fillet_*`/`chamfer_*`/`move_face`/`split_solid_by_plane`/`move_body`/`boolean_union`)
может «пройти» (`Update()==true`), оставив деталь в ошибке. После каждой операции вызывай `validate_part`:
**пустой результат = чисто; непустой = провал** (напр. `et3dError54`) — не продолжай и не экспортируй,
исправь или переделай другим методом. Перед `export_step` / выдачей результата — обязательно чистый `validate_part`.
(`move_face`/`move_body`/`boolean_union` сами дописывают итог `validate_part` в ответ, но проверяй явно после
формообразующих тоже.)
**2. Проверка построения приходит сама — читай её.** Любая операция может «пройти»
(`Create()/Update()==true`), оставив деталь в ошибке, поэтому **каждая мутирующая операция**
(`extrude`/`revolve`/`fillet_edge`/`chamfer_edge`/`hole`/`pattern`/`mirror`/`shell`/`rib`/`sweep`/`loft`/
`draft`/`move_face`/`move_body`/`boolean_union`/`set_variable`/сборочные) **сама дописывает итог проверки
к своему ответу**: «Построение чистое» = порядок, «⚠ Внимание: N операц. в ошибке» (напр. `et3dError54`)
= провал — не продолжай и не экспортируй, исправь или переделай другим методом. Отдельный `validate_part`
после каждого шага **не нужен**; зови его вручную, только если авто-проверку выключили
(`set_auto_validate(false)` или `KOMPAS_MCP_AUTOVALIDATE=0` — так делают на тяжёлой модели, где
проверка после каждого шага заметно тормозит). Перед `export_step`/выдачей результата убедись, что
последняя операция отчиталась чисто.
## Инструменты MCP
**Реализованы (83 инструмента):**
- *Система/документы:* `kompas_connect`, `kompas_set_visible`, `kompas_status`; `document_create|open|save|save_as|close|active`.
- *Эскиз:* `sketch_create`, `sketch_create_on_face[_index]`, `sketch_create_on_offset_plane`, `sketch_add_line|circle|rectangle|axis|arc|arc_3points|ellipse|polyline|polygon|spline|point`, `sketch_close`.
- *Формообразующие:* `extrude_boss|cut`, `revolve_boss|cut`, `fillet_edge[_index]`, `chamfer_edge[_index]`, `shell`, `rib`, `sweep`, `loft`, `draft`, `rebuild`.
- *Отверстия:* `hole`, `hole_counterbore`, `hole_countersink`, `hole_conic`.
- *Массивы и зеркало:* `linear_pattern`, `circular_pattern`, `mirror_operation`, `mirror_body`.
- *Прямое редактирование (без дерева, в т.ч. импортированная B-rep):* `move_face` (сдвинуть грань на N мм по нормали), `split_solid_by_plane` (рассечь тело плоскостью), `move_body` (сдвинуть тело на вектор), `boolean_union` (объединить тела).
- *Переменные:* `create_variable`, `set_variable`, `delete_variable`.
**Реализованы (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`.
- *Отверстия:* `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` (объединить тела).
- *Переменные:* `set_variable` (создаёт, если нет — проверять заранее не нужно), `delete_variable`.
- *Обмен:* `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`.
- *Запрос/осмотр:* `get_part_info`, `get_bounding_box`, `list_faces`, `list_edges`, `list_components`, `list_bodies`, `list_features`, `list_variables`, `measure`, `describe_model|face|edge` (структурный осмотр без снимка), `model_snapshot`.
- *Проверка:* `validate_part` (операции в ошибке / требующие перестроения).
- *Запрос/осмотр:* `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`.
- *Проверка:* `validate_part` — нужен, только если авто-проверка выключена (см. правило 2).
Набор растёт от версии к версии — актуальный перечень с описаниями параметров отдаёт сам
MCP-сервер; если инструмента под задачу нет, собирай результат из имеющихся общих операций.
@@ -64,10 +69,14 @@ MCP-сервер; если инструмента под задачу нет, с
1. `kompas_connect` (+ `kompas_set_visible true`).
2. `document_create part`.
3. `sketch_create XOY``sketch_add_*``sketch_close`.
4. `extrude_boss`/`revolve_boss`/… → **`validate_part`** (правило 2).
5. **Осмотр — структурно:** `describe_model` (паспорт: габарит, МЦХ, топология, дерево). Снимок — только если нужен визуальный контроль.
6. Итерация «на грани»: `describe_model`/`list_faces` (+`describe_face`) → `sketch_create_on_face_index` → операция`validate_part`.
3. `sketch_create(plane="XOY", entities=[…])`**весь контур одним вызовом**; эскиз закрывается сам (`autoClose` по умолчанию).
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=[…])` → операция.
**Не разбивай построение на лишние вызовы.** Прямоугольник с четырьмя отверстиями — это ОДИН
`sketch_create` со списком из пяти примитивов, а не шесть вызовов. Скругление восьми рёбер — один
`fillet_edge(radius, edgeIndices=[…])`, а не восемь. Каждый лишний вызов — лишний шанс сбиться.
## Работа с импортом / сборками
@@ -81,28 +90,33 @@ MCP-сервер; если инструмента под задачу нет, с
компоненты пишутся как отдельные `.m3d` рядом; открывать деталь самостоятельным документом
(`OpenSourceDocument`). Гашение видимости компонента на снимок **не влияет** — изоляция так не делается.
4. **Осмотр детали — структурно:** `describe_model` (габарит по осям → какая ось «высота», МЦХ,
тела, топология). Грани/рёбра под операцию — `list_faces`/`describe_face`, `measure`. `model_snapshot`
тела, топология). Грани/рёбра под операцию — `list_faces`/`list_faces(index=N)`, `measure`. `model_snapshot`
(или стандартные проекции `#Спереди/#Сверху/#Слева/#Справа/#Изометрия`) — **только** если нужен
визуально-пространственный контроль формы.
5. **Модификация** на «тупой» импортированной B-rep (после каждой операции — `validate_part`):
- *Простой случай* — `move_face`: сдвинуть плоскую грань на +N мм (наружу) или −N (внутрь).
⚠ На сложном торце (с отверстием/пазом) FaceMover может дать `et3dError54`.
5. **Модификация** на «тупой» импортированной B-rep (итог проверки читаешь в ответе каждой операции):
- *Простой случай* — `move_face`: сдвинуть плоскую грань на +N мм (наружу) или −N (внутрь);
грань бери по `faceIndex` из `list_faces`. ⚠ На сложном торце (с отверстием/пазом) FaceMover
может дать `et3dError54`.
- *Вставка N мм в середину призматической ножки* (надёжно, грани совпадают):
`split_solid_by_plane(plane, offset)``move_body(индекс верхнего тела, dz=N)`
`move_face(точка на грани реза, +N)` (мост) → `boolean_union()`.
6. **Проверка перед выдачей:** `validate_part` — чисто; затем **Экспорт STEP** `export_step(path, format=auto|ap203|ap214|ap242)`.
`move_face(distance=+N, точка на грани реза)` (мост) → `boolean_union()`.
6. **Проверка перед выдачей:** последняя операция отчиталась «Построение чистое»; затем
**Экспорт STEP** `export_step(path, format=auto|ap203|ap214|ap242)`.
## Эвристики и подводные камни
- **Зрение — структурное (правило 1).** Осматривай через `describe_model`/`describe_face`/`describe_edge`/`measure`,
а не снимком. `model_snapshot` — только для визуально-пространственных вопросов; не гоняй картинку зря.
- **`validate_part` после каждой операции (правило 2).** `Update()==true` ≠ успех; непустой `validate_part`
- **Зрение — структурное (правило 1).** Осматривай через `describe_model` / `list_faces(index=…)` /
`list_edges(index=…)` / `measure`, а не снимком. `model_snapshot` — только для визуально-пространственных
вопросов; не гоняй картинку зря.
- **Итог проверки — в ответе операции (правило 2).** `Create()/Update()==true` ≠ успех; «⚠ N операц. в ошибке»
(напр. `et3dError54`) — деталь невалидна, в STEP/печать не брать.
- **Грань/ребро по индексу надёжнее, чем по точке.** Сначала `list_faces`/`list_edges` (или `describe_model`),
затем `*_on_face_index` / `*_edge_index`. По точке (`SelectByPoint`) — только когда индекс не подходит.
- **Направление операции.** `extrude_boss(..., forward)` / `move_face(distance±)` может уйти «не туда» —
сверяй результат `get_bounding_box`/`describe_model` (числами), снимок лишь при необходимости.
- **МЦХ.** Объём/масса меняющейся геометрии бери через `get_part_info`/`describe_model`; перед чтением после правок — `rebuild`.
- **Грань/ребро по индексу надёжнее, чем по точке.** Сначала `list_faces`/`list_edges`, затем передавай
`faceIndex`/`edgeIndices` в `sketch_create`, `hole`, `move_face`, `fillet_edge`, `chamfer_edge`.
Координаты точки (`SelectByPoint`) — только когда индекс не подходит. Если выбирал точкой, ответ
подскажет, какой это оказался объект.
- **Направление операции.** `extrude(..., forward)` / `move_face(distance±)` может уйти «не туда» —
сверяй результат числами (`describe_model(sections="box,mass")`), снимок лишь при необходимости.
- **МЦХ.** Объём/масса меняющейся геометрии — `describe_model(sections="mass")`; перед чтением после правок — `rebuild`.
- **id эскизов/операций** недействительны после смены активного документа (create/open/close).
- **Единицы — мм** (геометрия) и кг (масса). Локальные координаты эскиза ≠ мировые координаты модели.
@@ -110,10 +124,10 @@ MCP-сервер; если инструмента под задачу нет, с
- **Прямое редактирование B-rep — реализовано полностью:** `move_face`, `split_solid_by_plane`,
`move_body`, `boolean_union`. Цепочка cut→spread→union проверена end-to-end (проставка
39.45→41.45 мм, `validate_part` чисто).
- **Параметрика ограничена.** `create_variable`/`set_variable` хранят и пересчитывают значения, но
двигают геометрию только в параметрической модели (размеры эскиза привязаны к именам переменных).
Если эскиз построен литеральными координатами, `set_variable` изменит значение, а не форму.
39.45→41.45 мм, построение чистое).
- **Параметрика ограничена.** `set_variable` хранит и пересчитывает значения, но двигает геометрию
только в параметрической модели (размеры эскиза привязаны к именам переменных). Если эскиз
построен литеральными координатами, `set_variable` изменит значение, а не форму.
- **2D и сборки покрыты частично.** Чертёж: стандартные виды, штамп, формат листа, размеры,
шероховатость, текст, выноски, техтребования. Сопряжения: совпадение и расстояние.
+9 -8
View File
@@ -19,7 +19,7 @@ description: >
Методический слой **поверх** навыка `kompas-3d`. Отвечает на вопрос **«как спроектировать, чтобы
напечаталось на FDM»**, а не «чем строить».
- **`kompas-3d`** = *чем и как строить* через MCP (эскиз→операция→осмотр→`validate_part`). Этот
- **`kompas-3d`** = *чем и как строить* через MCP (эскиз→операция→осмотр→проверка построения). Этот
навык **опирается** на него для механики.
- Этот навык = *какие правила геометрии* соблюдать, чтобы FDM-печать удалась.
- **Слайсинг — вне границ.** Навык не нарезает и не оценивает g-code.
@@ -60,8 +60,9 @@ description: >
некритичными/скрытыми.
- Зафиксируй ориентацию и проектируй под неё.
**2. Чек-лист печатнопригодности перед выдачей** (ниже). Сначала **`validate_part`** (деталь
*валидна*), затем **FDM-чек-лист** (деталь *печатнопригодна*) — разные проверки. **Гео-аудит
**2. Чек-лист печатнопригодности перед выдачей** (ниже). Сначала **валидность построения** (её
дописывает к ответу каждая операция, см. `kompas-3d`; вручную — `validate_part`), затем
**FDM-чек-лист** (деталь *печатнопригодна*) — разные проверки. **Гео-аудит
эвристический и не доказывает печатнопригодность** (не ловит путь нагрузки/анизотропию).
## Рабочий цикл
@@ -75,7 +76,7 @@ description: >
Числа — в `references/fdm-rules.md`.
4. **Гео-аудит** (`references/geometry-audit.md`) — инструментами осмотра.
5. **Предусловия экспорта**: единое тело/манифолд (`boolean_union` при необходимости) →
`validate_part` чисто.
построение чистое.
6. **Чек-лист** → экспорт **через `export_step`**.
## Чек-лист печатнопригодности
@@ -94,14 +95,14 @@ description: >
- [ ] Мин. элементы/текст ≥ порогов; аспект тонких выступов ≤4–5×.
- [ ] Бобышки/инсёрты (бор отверстия ≤ OD инсёрта, ставить с Z-грани)/резьба/защёлки (изгиб в XY) по правилам.
- [ ] Полости — дренаж/вент; критичные поверхности не под «лесенкой»/поддержкой.
- [ ] Предусловия экспорта: единое тело/манифолд; `validate_part` чисто.
- [ ] Предусловия экспорта: единое тело/манифолд; построение чистое.
## Гео-аудит (кратко)
Лёгкая самопроверка построенной модели **существующими** инструментами осмотра MCP:
`describe_model` / `list_faces` / `describe_face` (нависания по нормалям нижних граней; цилиндры с
горизонтальной осью → нужен teardrop), `get_bounding_box` (как ось слоёв соотносится с габаритом),
`measure` (номиналы/зазоры), `list_bodies` + `validate_part` (единое тело). **Границы и методика —
`describe_model` / `list_faces` / `list_faces(index=N)` (нависания по нормалям нижних граней; цилиндры с
горизонтальной осью → нужен teardrop), `describe_model(sections="box")` (как ось слоёв соотносится с габаритом),
`measure` (номиналы/зазоры), `list_bodies` + чистое построение (единое тело). **Границы и методика —
`references/geometry-audit.md`.** Аудит эвристический; истинная мин. толщина стенки и полный детект
криволинейных нависаний не решаются — это **не приговор и не доказательство печатнопригодности**.
@@ -7,11 +7,11 @@
| Проверка | Как | Статус |
|---|---|---|
| Нависания (приближённо) | `list_faces`/`describe_face`: для **нижних** граней угол поверхности от вертикали; > `θ_max` → флаг | ✅ плоские; ⚠️ криволинейные грубо |
| Ориентация (геом. прокси) | `get_bounding_box`: как ось слоёв соотносится с габаритом | ⚠️ длинная ось ≠ путь нагрузки |
| Горизонтальные круглые отверстия | `describe_face`: цилиндр с горизонтальной осью → «нужен teardrop» | ✅ |
| Номиналы / зазоры / габариты | `measure` между гранями; `get_bounding_box` | ✅ |
| Тело / манифолд перед выдачей | `list_bodies` (одно тело?), `validate_part` | ✅ |
| Нависания (приближённо) | `list_faces`, затем `list_faces(index=N)`: для **нижних** граней угол поверхности от вертикали; > `θ_max` → флаг | ✅ плоские; ⚠️ криволинейные грубо |
| Ориентация (геом. прокси) | `describe_model(sections="box")`: как ось слоёв соотносится с габаритом | ⚠️ длинная ось ≠ путь нагрузки |
| Горизонтальные круглые отверстия | `list_faces(index=N)`: цилиндр с горизонтальной осью → «нужен teardrop» | ✅ |
| Номиналы / зазоры / габариты | `measure` между гранями; `describe_model(sections="box")` | ✅ |
| Тело / манифолд перед выдачей | `list_bodies` (одно тело?), итог проверки построения в ответе операции | ✅ |
## Граница честности
@@ -26,7 +26,7 @@
## Как применять в цикле
1. После построения и `validate_part` — пройти таблицу выше сверху вниз.
1. После построения, убедившись что оно чистое, — пройти таблицу выше сверху вниз.
2. Каждый флаг — сверить с соответствующим правилом `fdm-rules.md` и решить: исправить геометрию
или принять осознанно.
3. Путь нагрузки и косметические грани взять из ответа пользователя (правило 1), не из габарита.
@@ -127,13 +127,32 @@ public sealed partial class PartModeler
/// <summary>Скруглить ребро детали по индексу из list_edges радиусом <paramref name="radius"/>.</summary>
public Task<int> FilletEdgeIndexAsync(int edgeIndex, double radius, CancellationToken ct = default)
=> FilletEdgesAsync([edgeIndex], radius, ct);
/// <summary>Скруглить одним скруглением несколько рёбер по индексам из list_edges
/// (одна операция в дереве вместо N). Возвращает id операции.</summary>
public Task<int> FilletEdgesAsync(IReadOnlyList<int> edgeIndices, double radius, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
return CreateFillet(part, SelectEdgeByIndex(part, edgeIndex), radius);
// Диапазон всех индексов проверяем до мутации модели.
var edges = RequireEdges(part, edgeIndices);
return CreateFillet(part, edges, radius);
}, ct);
private int CreateFillet(ksPart part, ksEntity edge, double radius)
/// <summary>Разобрать список индексов рёбер в сущности (с проверкой пустоты и диапазона).</summary>
private static List<ksEntity> RequireEdges(ksPart part, IReadOnlyList<int> edgeIndices)
{
ArgumentNullException.ThrowIfNull(edgeIndices);
var indices = edgeIndices.Distinct().ToList();
if (indices.Count == 0)
throw new ArgumentException("Нужен хотя бы один индекс ребра.", nameof(edgeIndices));
return indices.Select(i => SelectEdgeByIndex(part, i)).ToList();
}
private int CreateFillet(ksPart part, ksEntity edge, double radius) => CreateFillet(part, [edge], radius);
private int CreateFillet(ksPart part, IReadOnlyList<ksEntity> edges, double radius)
{
if (radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius), "Радиус скругления должен быть > 0.");
@@ -145,7 +164,7 @@ public sealed partial class PartModeler
def.tangent = false;
var arr = def.array() as ksEntityCollection
?? throw new InvalidOperationException("array() скругления вернул не ksEntityCollection.");
arr.Add(edge);
foreach (var edge in edges) arr.Add(edge);
if (!entity.Create())
throw new InvalidOperationException("Create() скругления вернул FALSE (радиус слишком велик для ребра?).");
@@ -168,13 +187,21 @@ public sealed partial class PartModeler
/// <summary>Снять фаску с ребра детали по индексу из list_edges катетом <paramref name="distance"/>.</summary>
public Task<int> ChamferEdgeIndexAsync(int edgeIndex, double distance, CancellationToken ct = default)
=> ChamferEdgesAsync([edgeIndex], distance, ct);
/// <summary>Снять одной операцией фаску с нескольких рёбер по индексам из list_edges.
/// Возвращает id операции.</summary>
public Task<int> ChamferEdgesAsync(IReadOnlyList<int> edgeIndices, double distance, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
return CreateChamfer(part, SelectEdgeByIndex(part, edgeIndex), distance);
var edges = RequireEdges(part, edgeIndices);
return CreateChamfer(part, edges, distance);
}, ct);
private int CreateChamfer(ksPart part, ksEntity edge, double distance)
private int CreateChamfer(ksPart part, ksEntity edge, double distance) => CreateChamfer(part, [edge], distance);
private int CreateChamfer(ksPart part, IReadOnlyList<ksEntity> edges, double distance)
{
if (distance <= 0) throw new ArgumentOutOfRangeException(nameof(distance), "Катет фаски должен быть > 0.");
@@ -186,7 +213,7 @@ public sealed partial class PartModeler
def.SetChamferParam(true, distance, distance);
var arr = def.array() as ksEntityCollection
?? throw new InvalidOperationException("array() фаски вернул не ksEntityCollection.");
arr.Add(edge);
foreach (var edge in edges) arr.Add(edge);
if (!entity.Create())
throw new InvalidOperationException("Create() фаски вернул FALSE (катет слишком велик для ребра?).");
+216 -126
View File
@@ -104,49 +104,108 @@ public sealed partial class PartModeler
return id;
}
/// <summary>
/// Добавить пакет примитивов в открытый эскиз за ОДИН заход на STA-поток. Целый контур
/// (десяток отрезков и окружностей) обходится одним COM-сеансом вместо вызова на примитив.
/// <paramref name="autoClose"/> — завершить редактирование эскиза сразу после построения.
/// Возвращает число добавленных примитивов.
/// </summary>
public Task<int> AddEntitiesAsync(int sketchId, IReadOnlyList<SketchEntity> entities, bool autoClose = false, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
ArgumentNullException.ThrowIfNull(entities);
var sketch = RequireOpenSketch(sketchId);
var editor = sketch.Editor!;
for (int i = 0; i < entities.Count; i++)
{
var e = entities[i] ?? throw new ArgumentException($"Примитив [{i}] пустой.", nameof(entities));
try
{
AddEntityCore(editor, e);
}
catch (Exception ex)
{
// Позиция в пакете — иначе по тексту ошибки не понять, какой из примитивов не построился.
throw new InvalidOperationException(
$"Примитив [{i}] ({e.Kind.ToString().ToLowerInvariant()}) не построен: {ex.Message}", ex);
}
}
if (autoClose) CloseSketchCore(sketch);
return entities.Count;
}, ct);
private void AddEntityCore(ksDocument2D editor, SketchEntity e)
{
switch (e.Kind)
{
case SketchEntityKind.Line:
LineCore(editor, Point(e, 0), Point(e, 1), MainLineStyle);
break;
case SketchEntityKind.Axis:
LineCore(editor, Point(e, 0), Point(e, 1), AxisLineStyle);
break;
case SketchEntityKind.Rectangle:
RectangleCore(editor, Point(e, 0), Point(e, 1));
break;
case SketchEntityKind.Circle:
CircleCore(editor, e.CenterX, e.CenterY, e.Radius);
break;
case SketchEntityKind.Arc:
ArcByAngleCore(editor, e.CenterX, e.CenterY, e.Radius, e.StartAngle, e.EndAngle, e.CounterClockwise);
break;
case SketchEntityKind.Arc3Points:
Arc3PointsCore(editor, Point(e, 0), Point(e, 1), Point(e, 2));
break;
case SketchEntityKind.Ellipse:
EllipseCore(editor, e.CenterX, e.CenterY, e.SemiMajor, e.SemiMinor, e.Angle);
break;
case SketchEntityKind.Polyline:
PolylineCore(editor, e.Points, e.Closed);
break;
case SketchEntityKind.Polygon:
PolygonCore(editor, e.CenterX, e.CenterY, e.VertexCount, e.Radius, e.Inscribed, e.Angle);
break;
case SketchEntityKind.Spline:
SplineCore(editor, e.Points, e.Closed);
break;
case SketchEntityKind.Point:
PointCore(editor, Point(e, 0));
break;
default:
throw new ArgumentOutOfRangeException(nameof(e), $"Неизвестный тип примитива: {e.Kind}.");
}
}
/// <summary>Точка пакета по позиции — с внятной ошибкой вместо IndexOutOfRange.</summary>
private static (double x, double y) Point(SketchEntity e, int index)
{
if (e.Points.Count <= index)
throw new ArgumentException(
$"Для типа {e.Kind.ToString().ToLowerInvariant()} нужно минимум {index + 1} точек, передано {e.Points.Count}.");
return e.Points[index];
}
/// <summary>Добавить отрезок в открытый эскиз.</summary>
public Task AddLineAsync(int sketchId, double x1, double y1, double x2, double y2, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var editor = RequireOpenSketch(sketchId).Editor!;
if (editor.ksLineSeg(x1, y1, x2, y2, MainLineStyle) == 0)
throw new InvalidOperationException("ksLineSeg вернул 0 (отрезок не создан).");
}, ct);
LineCore(RequireOpenSketch(sketchId).Editor!, (x1, y1), (x2, y2), MainLineStyle), ct);
/// <summary>Добавить окружность в открытый эскиз.</summary>
public Task AddCircleAsync(int sketchId, double centerX, double centerY, double radius, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius), "Радиус должен быть > 0.");
var editor = RequireOpenSketch(sketchId).Editor!;
if (editor.ksCircle(centerX, centerY, radius, MainLineStyle) == 0)
throw new InvalidOperationException("ksCircle вернул 0 (окружность не создана).");
}, ct);
CircleCore(RequireOpenSketch(sketchId).Editor!, centerX, centerY, radius), ct);
/// <summary>Добавить прямоугольник (4 отрезка) по двум углам.</summary>
public Task AddRectangleAsync(int sketchId, double x1, double y1, double x2, double y2, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var editor = RequireOpenSketch(sketchId).Editor!;
void Seg(double ax, double ay, double bx, double by)
{
if (editor.ksLineSeg(ax, ay, bx, by, MainLineStyle) == 0)
throw new InvalidOperationException("ksLineSeg вернул 0 (сторона прямоугольника не создана).");
}
Seg(x1, y1, x2, y1);
Seg(x2, y1, x2, y2);
Seg(x2, y2, x1, y2);
Seg(x1, y2, x1, y1);
}, ct);
RectangleCore(RequireOpenSketch(sketchId).Editor!, (x1, y1), (x2, y2)), ct);
/// <summary>Добавить осевую линию (стиль «осевая») в эскиз — ось для операции вращения.</summary>
public Task AddAxisAsync(int sketchId, double x1, double y1, double x2, double y2, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var editor = RequireOpenSketch(sketchId).Editor!;
if (editor.ksLineSeg(x1, y1, x2, y2, AxisLineStyle) == 0)
throw new InvalidOperationException("ksLineSeg (ось) вернул 0 (ось не создана).");
}, ct);
LineCore(RequireOpenSketch(sketchId).Editor!, (x1, y1), (x2, y2), AxisLineStyle), ct);
/// <summary>Завершить редактирование эскиза.</summary>
public Task CloseSketchAsync(int sketchId, CancellationToken ct = default)
@@ -160,128 +219,159 @@ public sealed partial class PartModeler
/// <summary>Добавить дугу по трём точкам (начало, точка на дуге, конец) в открытый эскиз.</summary>
public Task AddArc3PointsAsync(int sketchId, double x1, double y1, double x2, double y2, double x3, double y3, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var editor = RequireOpenSketch(sketchId).Editor!;
if (editor.ksArcBy3Points(x1, y1, x2, y2, x3, y3, MainLineStyle) == 0)
throw new InvalidOperationException("ksArcBy3Points вернул 0 (дуга не создана — три точки коллинеарны?).");
}, ct);
Arc3PointsCore(RequireOpenSketch(sketchId).Editor!, (x1, y1), (x2, y2), (x3, y3)), ct);
/// <summary>Добавить дугу по центру, радиусу и углам (градусы) в открытый эскиз.</summary>
public Task AddArcByAngleAsync(int sketchId, double centerX, double centerY, double radius, double startAngle, double endAngle, bool counterClockwise = true, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
SketchGeometry.RequirePositive(radius, nameof(radius));
var editor = RequireOpenSketch(sketchId).Editor!;
var dir = SketchGeometry.ArcDirection(counterClockwise);
if (editor.ksArcByAngle(centerX, centerY, radius, startAngle, endAngle, dir, MainLineStyle) == 0)
throw new InvalidOperationException("ksArcByAngle вернул 0 (дуга не создана).");
}, ct);
ArcByAngleCore(RequireOpenSketch(sketchId).Editor!, centerX, centerY, radius, startAngle, endAngle, counterClockwise), ct);
/// <summary>Добавить эллипс: центр, полуоси A/B (мм), угол наклона большой оси к X (градусы).</summary>
public Task AddEllipseAsync(int sketchId, double centerX, double centerY, double semiMajor, double semiMinor, double angle = 0, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
SketchGeometry.RequirePositive(semiMajor, nameof(semiMajor));
SketchGeometry.RequirePositive(semiMinor, nameof(semiMinor));
var editor = RequireOpenSketch(sketchId).Editor!;
ksEllipseParam? p = null;
try
{
p = NewParam<ksEllipseParam>(StructType2DEnum.ko_EllipseParam);
// ВНИМАНИЕ: interop генерирует свойства A/B с ЗАГЛАВНОЙ (не a/b как в SDK-доках). Регистр в C# важен.
p.xc = centerX; p.yc = centerY;
p.A = semiMajor; p.B = semiMinor;
p.angle = angle; p.style = MainLineStyle;
if (editor.ksEllipse(p) == 0)
throw new InvalidOperationException("ksEllipse вернул 0 (эллипс не создан).");
}
finally { ReleaseCom(p); }
}, ct);
EllipseCore(RequireOpenSketch(sketchId).Editor!, centerX, centerY, semiMajor, semiMinor, angle), ct);
/// <summary>Добавить ломаную (цепочку отрезков) по списку точек. closed замыкает последнюю с первой.</summary>
public Task AddPolylineAsync(int sketchId, IReadOnlyList<(double x, double y)> points, bool closed = false, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
// closed-ломаная из 2 точек не образует площадь — требуем минимум 3.
SketchGeometry.RequirePoints(points, closed ? 3 : 2, nameof(points));
var editor = RequireOpenSketch(sketchId).Editor!;
for (int i = 0; i + 1 < points.Count; i++)
Seg(editor, points[i], points[i + 1]);
if (closed)
Seg(editor, points[^1], points[0]);
static void Seg(ksDocument2D ed, (double x, double y) a, (double x, double y) b)
{
if (ed.ksLineSeg(a.x, a.y, b.x, b.y, MainLineStyle) == 0)
throw new InvalidOperationException("ksLineSeg вернул 0 (сегмент ломаной не создан).");
}
}, ct);
PolylineCore(RequireOpenSketch(sketchId).Editor!, points, closed), ct);
/// <summary>Добавить правильный многоугольник: центр, vertexCount вершин, radius (мм),
/// inscribed=true — вершины на окружности (вписанный), false — стороны касаются (описанный),
/// angle — поворот первой вершины (градусы).</summary>
public Task AddPolygonAsync(int sketchId, double centerX, double centerY, int vertexCount, double radius, bool inscribed = true, double angle = 0, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
SketchGeometry.RequireVertexCount(vertexCount);
SketchGeometry.RequirePositive(radius, nameof(radius));
var editor = RequireOpenSketch(sketchId).Editor!;
ksRegularPolygonParam? p = null;
try
{
p = NewParam<ksRegularPolygonParam>(StructType2DEnum.ko_RegularPolygonParam);
p.count = vertexCount; p.xc = centerX; p.yc = centerY;
p.radius = radius; p.ang = angle;
p.describe = SketchGeometry.PolygonDescribe(inscribed);
p.style = MainLineStyle;
if (editor.ksRegularPolygon(p, 0) == 0)
throw new InvalidOperationException("ksRegularPolygon вернул 0 (многоугольник не создан).");
}
finally { ReleaseCom(p); }
}, ct);
PolygonCore(RequireOpenSketch(sketchId).Editor!, centerX, centerY, vertexCount, radius, inscribed, angle), ct);
/// <summary>Добавить сплайн (кубический NURBS, порядок 4) через список точек. closed замыкает кривую.</summary>
public Task AddSplineAsync(int sketchId, IReadOnlyList<(double x, double y)> points, bool closed = false, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
SketchGeometry.RequirePoints(points, 2, nameof(points));
var editor = RequireOpenSketch(sketchId).Editor!;
if (editor.ksNurbs(SplineOrder, closed, MainLineStyle) == 0)
throw new InvalidOperationException("ksNurbs вернул 0 (сплайн не открыт).");
// ksNurbs открыл составной объект — ksEndObj ОБЯЗАТЕЛЕН в любом исходе, иначе редактор «застрянет».
var completed = false;
try
{
foreach (var (x, y) in points)
{
ksNurbsPointParam? np = null;
try
{
np = NewParam<ksNurbsPointParam>(StructType2DEnum.ko_NurbsPointParam);
np.x = x; np.y = y; np.weight = 1.0;
if (editor.ksNurbsPoint(np) == 0)
throw new InvalidOperationException("ksNurbsPoint вернул 0 (узел сплайна не добавлен).");
}
finally { ReleaseCom(np); }
}
completed = true;
}
finally
{
// Закрываем составной объект всегда; ошибку завершения сообщаем только на успешном пути,
// чтобы не подменить исходное исключение из цикла.
var end = editor.ksEndObj();
if (completed && end == 0)
throw new InvalidOperationException("ksEndObj вернул 0 (сплайн не завершён).");
}
}, ct);
SplineCore(RequireOpenSketch(sketchId).Editor!, points, closed), ct);
/// <summary>Добавить точку в открытый эскиз (опорная точка для построений).</summary>
public Task AddPointAsync(int sketchId, double x, double y, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
=> _dispatcher.InvokeAsync(() => PointCore(RequireOpenSketch(sketchId).Editor!, (x, y)), ct);
// ---- Синхронные ядра примитивов ----------------------------------------
// Общие для одиночного вызова и для пакета: пакет прогоняет их в одном заходе на STA-поток.
private static void LineCore(ksDocument2D editor, (double x, double y) a, (double x, double y) b, int style)
{
if (editor.ksLineSeg(a.x, a.y, b.x, b.y, style) == 0)
throw new InvalidOperationException("ksLineSeg вернул 0 (отрезок не создан).");
}
private static void CircleCore(ksDocument2D editor, double centerX, double centerY, double radius)
{
if (radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius), "Радиус должен быть > 0.");
if (editor.ksCircle(centerX, centerY, radius, MainLineStyle) == 0)
throw new InvalidOperationException("ksCircle вернул 0 (окружность не создана).");
}
private static void RectangleCore(ksDocument2D editor, (double x, double y) a, (double x, double y) b)
{
LineCore(editor, (a.x, a.y), (b.x, a.y), MainLineStyle);
LineCore(editor, (b.x, a.y), (b.x, b.y), MainLineStyle);
LineCore(editor, (b.x, b.y), (a.x, b.y), MainLineStyle);
LineCore(editor, (a.x, b.y), (a.x, a.y), MainLineStyle);
}
private static void Arc3PointsCore(ksDocument2D editor, (double x, double y) a, (double x, double y) b, (double x, double y) c)
{
if (editor.ksArcBy3Points(a.x, a.y, b.x, b.y, c.x, c.y, MainLineStyle) == 0)
throw new InvalidOperationException("ksArcBy3Points вернул 0 (дуга не создана — три точки коллинеарны?).");
}
private static void ArcByAngleCore(ksDocument2D editor, double centerX, double centerY, double radius, double startAngle, double endAngle, bool counterClockwise)
{
SketchGeometry.RequirePositive(radius, nameof(radius));
var dir = SketchGeometry.ArcDirection(counterClockwise);
if (editor.ksArcByAngle(centerX, centerY, radius, startAngle, endAngle, dir, MainLineStyle) == 0)
throw new InvalidOperationException("ksArcByAngle вернул 0 (дуга не создана).");
}
private void EllipseCore(ksDocument2D editor, double centerX, double centerY, double semiMajor, double semiMinor, double angle)
{
SketchGeometry.RequirePositive(semiMajor, nameof(semiMajor));
SketchGeometry.RequirePositive(semiMinor, nameof(semiMinor));
ksEllipseParam? p = null;
try
{
var editor = RequireOpenSketch(sketchId).Editor!;
if (editor.ksPoint(x, y, PointStyle) == 0)
throw new InvalidOperationException("ksPoint вернул 0 (точка не создана).");
}, ct);
p = NewParam<ksEllipseParam>(StructType2DEnum.ko_EllipseParam);
// ВНИМАНИЕ: interop генерирует свойства A/B с ЗАГЛАВНОЙ (не a/b как в SDK-доках). Регистр в C# важен.
p.xc = centerX; p.yc = centerY;
p.A = semiMajor; p.B = semiMinor;
p.angle = angle; p.style = MainLineStyle;
if (editor.ksEllipse(p) == 0)
throw new InvalidOperationException("ksEllipse вернул 0 (эллипс не создан).");
}
finally { ReleaseCom(p); }
}
private static void PolylineCore(ksDocument2D editor, IReadOnlyList<(double x, double y)> points, bool closed)
{
// closed-ломаная из 2 точек не образует площадь — требуем минимум 3.
SketchGeometry.RequirePoints(points, closed ? 3 : 2, nameof(points));
for (int i = 0; i + 1 < points.Count; i++)
LineCore(editor, points[i], points[i + 1], MainLineStyle);
if (closed)
LineCore(editor, points[^1], points[0], MainLineStyle);
}
private void PolygonCore(ksDocument2D editor, double centerX, double centerY, int vertexCount, double radius, bool inscribed, double angle)
{
SketchGeometry.RequireVertexCount(vertexCount);
SketchGeometry.RequirePositive(radius, nameof(radius));
ksRegularPolygonParam? p = null;
try
{
p = NewParam<ksRegularPolygonParam>(StructType2DEnum.ko_RegularPolygonParam);
p.count = vertexCount; p.xc = centerX; p.yc = centerY;
p.radius = radius; p.ang = angle;
p.describe = SketchGeometry.PolygonDescribe(inscribed);
p.style = MainLineStyle;
if (editor.ksRegularPolygon(p, 0) == 0)
throw new InvalidOperationException("ksRegularPolygon вернул 0 (многоугольник не создан).");
}
finally { ReleaseCom(p); }
}
private void SplineCore(ksDocument2D editor, IReadOnlyList<(double x, double y)> points, bool closed)
{
SketchGeometry.RequirePoints(points, 2, nameof(points));
if (editor.ksNurbs(SplineOrder, closed, MainLineStyle) == 0)
throw new InvalidOperationException("ksNurbs вернул 0 (сплайн не открыт).");
// ksNurbs открыл составной объект — ksEndObj ОБЯЗАТЕЛЕН в любом исходе, иначе редактор «застрянет».
var completed = false;
try
{
foreach (var (x, y) in points)
{
ksNurbsPointParam? np = null;
try
{
np = NewParam<ksNurbsPointParam>(StructType2DEnum.ko_NurbsPointParam);
np.x = x; np.y = y; np.weight = 1.0;
if (editor.ksNurbsPoint(np) == 0)
throw new InvalidOperationException("ksNurbsPoint вернул 0 (узел сплайна не добавлен).");
}
finally { ReleaseCom(np); }
}
completed = true;
}
finally
{
// Закрываем составной объект всегда; ошибку завершения сообщаем только на успешном пути,
// чтобы не подменить исходное исключение из цикла.
var end = editor.ksEndObj();
if (completed && end == 0)
throw new InvalidOperationException("ksEndObj вернул 0 (сплайн не завершён).");
}
}
private static void PointCore(ksDocument2D editor, (double x, double y) p)
{
if (editor.ksPoint(p.x, p.y, PointStyle) == 0)
throw new InvalidOperationException("ksPoint вернул 0 (точка не создана).");
}
}
@@ -0,0 +1,44 @@
namespace Kompas.Mcp.Core.Modeling;
/// <summary>Тип 2D-примитива эскиза.</summary>
public enum SketchEntityKind
{
Line,
Circle,
Rectangle,
Arc,
Arc3Points,
Ellipse,
Polyline,
Polygon,
Spline,
Point,
Axis,
}
/// <summary>
/// Описание одного примитива эскиза. Пакет таких описаний строится за один заход на STA-поток
/// (<see cref="PartModeler.AddEntitiesAsync"/>), поэтому целый контур обходится одним COM-сеансом
/// вместо вызова на каждую линию.
/// </summary>
public sealed record SketchEntity
{
public required SketchEntityKind Kind { get; init; }
/// <summary>Точки в плоскости эскиза (мм): отрезок/ось — 2, прямоугольник — 2 угла,
/// дуга по трём точкам — 3, ломаная/сплайн — N, точка — 1.</summary>
public IReadOnlyList<(double X, double Y)> Points { get; init; } = [];
public double CenterX { get; init; }
public double CenterY { get; init; }
public double Radius { get; init; }
public double StartAngle { get; init; }
public double EndAngle { get; init; }
public double SemiMajor { get; init; }
public double SemiMinor { get; init; }
public double Angle { get; init; }
public int VertexCount { get; init; }
public bool Closed { get; init; }
public bool CounterClockwise { get; init; } = true;
public bool Inscribed { get; init; } = true;
}
@@ -0,0 +1,204 @@
using System.ComponentModel;
using System.Text.Json.Serialization;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>
/// Описание одного примитива эскиза во входе инструмента. Один объект вместо отдельного
/// инструмента на каждый тип: контур строится списком за один вызов.
/// </summary>
public sealed class SketchEntityInput
{
[JsonPropertyName("type")]
[Description("Тип примитива: line | circle | rectangle | arc | arc3points | ellipse | polyline | polygon | spline | point | axis")]
public string Type { get; set; } = "";
[JsonPropertyName("points")]
[Description("Точки в плоскости эскиза (мм). line/axis — 2 точки; rectangle — 2 противоположных угла; " +
"arc3points — 3 (начало, промежуточная на дуге, конец); polyline/spline — N точек; point — 1")]
public SketchPoint[]? Points { get; set; }
[JsonPropertyName("centerX")]
[Description("X центра, мм — для circle | arc | ellipse | polygon")]
public double? CenterX { get; set; }
[JsonPropertyName("centerY")]
[Description("Y центра, мм — для circle | arc | ellipse | polygon")]
public double? CenterY { get; set; }
[JsonPropertyName("radius")]
[Description("Радиус, мм — для circle | arc; для polygon — радиус окружности вершин/сторон")]
public double? Radius { get; set; }
[JsonPropertyName("startAngle")]
[Description("Начальный угол дуги от оси X, градусы — для arc")]
public double? StartAngle { get; set; }
[JsonPropertyName("endAngle")]
[Description("Конечный угол дуги от оси X, градусы — для arc")]
public double? EndAngle { get; set; }
[JsonPropertyName("semiMajor")]
[Description("Большая полуось, мм — для ellipse")]
public double? SemiMajor { get; set; }
[JsonPropertyName("semiMinor")]
[Description("Малая полуось, мм — для ellipse")]
public double? SemiMinor { get; set; }
[JsonPropertyName("angle")]
[Description("Угол поворота, градусы: наклон большой оси (ellipse) или поворот первой вершины (polygon). По умолчанию 0")]
public double? Angle { get; set; }
[JsonPropertyName("vertexCount")]
[Description("Число вершин (>=3) — для polygon")]
public int? VertexCount { get; set; }
[JsonPropertyName("closed")]
[Description("Замкнуть контур — для polyline | spline. По умолчанию false")]
public bool? Closed { get; set; }
[JsonPropertyName("counterClockwise")]
[Description("Дуга против часовой стрелки — для arc. По умолчанию true")]
public bool? CounterClockwise { get; set; }
[JsonPropertyName("inscribed")]
[Description("Вершины на окружности радиуса (true, вписанный) или стороны касаются её (false, описанный) — для polygon. По умолчанию true")]
public bool? Inscribed { get; set; }
}
/// <summary>
/// Разбор входных примитивов в модель Core. Сообщения об ошибках называют недостающий параметр
/// и позицию в списке: у слитого инструмента схема не может запретить неверную комбинацию полей,
/// поэтому её ловит валидация — и агент исправляется с первой попытки.
/// </summary>
public static class SketchEntityInputs
{
private const string KnownTypes = "line | circle | rectangle | arc | arc3points | ellipse | polyline | polygon | spline | point | axis";
public static IReadOnlyList<SketchEntity> Parse(IReadOnlyList<SketchEntityInput>? entities)
{
if (entities is null || entities.Count == 0) return [];
var result = new List<SketchEntity>(entities.Count);
for (int i = 0; i < entities.Count; i++)
{
var e = entities[i] ?? throw new ArgumentException($"entities[{i}]: пустой объект.");
try
{
result.Add(ParseOne(e));
}
catch (ArgumentException ex)
{
throw new ArgumentException($"entities[{i}]: {ex.Message}");
}
}
return result;
}
private static SketchEntity ParseOne(SketchEntityInput e)
{
var kind = ParseKind(e.Type);
var points = (e.Points ?? []).Select(p => (p.X, p.Y)).ToList();
switch (kind)
{
case SketchEntityKind.Line:
case SketchEntityKind.Axis:
case SketchEntityKind.Rectangle:
RequirePoints(points, 2, kind, kind == SketchEntityKind.Rectangle
? "два противоположных угла"
: "начало и конец");
break;
case SketchEntityKind.Arc3Points:
RequirePoints(points, 3, kind, "начало, промежуточную точку на дуге и конец");
break;
case SketchEntityKind.Point:
RequirePoints(points, 1, kind, "одну точку");
break;
case SketchEntityKind.Polyline:
case SketchEntityKind.Spline:
RequirePoints(points, 2, kind, "список точек");
break;
case SketchEntityKind.Circle:
RequireCenter(e, kind);
Require(e.Radius, "radius", kind);
break;
case SketchEntityKind.Arc:
RequireCenter(e, kind);
Require(e.Radius, "radius", kind);
Require(e.StartAngle, "startAngle", kind);
Require(e.EndAngle, "endAngle", kind);
break;
case SketchEntityKind.Ellipse:
RequireCenter(e, kind);
Require(e.SemiMajor, "semiMajor", kind);
Require(e.SemiMinor, "semiMinor", kind);
break;
case SketchEntityKind.Polygon:
RequireCenter(e, kind);
Require(e.Radius, "radius", kind);
Require(e.VertexCount, "vertexCount", kind);
break;
}
return new SketchEntity
{
Kind = kind,
Points = points,
CenterX = e.CenterX ?? 0,
CenterY = e.CenterY ?? 0,
Radius = e.Radius ?? 0,
StartAngle = e.StartAngle ?? 0,
EndAngle = e.EndAngle ?? 0,
SemiMajor = e.SemiMajor ?? 0,
SemiMinor = e.SemiMinor ?? 0,
Angle = e.Angle ?? 0,
VertexCount = e.VertexCount ?? 0,
Closed = e.Closed ?? false,
CounterClockwise = e.CounterClockwise ?? true,
Inscribed = e.Inscribed ?? true,
};
}
private static SketchEntityKind ParseKind(string? type) => (type ?? "").Trim().ToLowerInvariant() switch
{
"line" => SketchEntityKind.Line,
"circle" => SketchEntityKind.Circle,
"rectangle" or "rect" => SketchEntityKind.Rectangle,
"arc" => SketchEntityKind.Arc,
"arc3points" or "arc3" => SketchEntityKind.Arc3Points,
"ellipse" => SketchEntityKind.Ellipse,
"polyline" => SketchEntityKind.Polyline,
"polygon" => SketchEntityKind.Polygon,
"spline" => SketchEntityKind.Spline,
"point" => SketchEntityKind.Point,
"axis" => SketchEntityKind.Axis,
_ => throw new ArgumentException($"неизвестный тип примитива '{type}'. Допустимо: {KnownTypes}."),
};
private static void RequirePoints(IReadOnlyList<(double, double)> points, int min, SketchEntityKind kind, string what)
{
if (points.Count < min)
throw new ArgumentException(
$"type={Name(kind)} требует points — {what} (минимум {min}), передано {points.Count}.");
}
private static void RequireCenter(SketchEntityInput e, SketchEntityKind kind)
{
Require(e.CenterX, "centerX", kind);
Require(e.CenterY, "centerY", kind);
}
private static void Require<T>(T? value, string name, SketchEntityKind kind) where T : struct
{
if (value is null)
throw new ArgumentException($"type={Name(kind)} требует параметр {name}.");
}
private static string Name(SketchEntityKind kind) => kind switch
{
SketchEntityKind.Arc3Points => "arc3points",
_ => kind.ToString().ToLowerInvariant(),
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
using System.ComponentModel;
using System.Text.Json.Serialization;
namespace Kompas.Mcp.Host.Tools;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>Точка эскиза в его плоскости (мм). Элемент списка для ломаной/сплайна.
/// JSON-имена зафиксированы lowercase (x/y), чтобы схема инструмента совпадала с контрактом.</summary>
@@ -60,6 +60,37 @@ public sealed class VariableService
return ReadValueFresh(name); // переменную перечитываем после перестроения (value пересчитан)
}, ct);
/// <summary>
/// Задать выражение переменной, создав её при отсутствии (upsert) — за один заход на STA-поток.
/// Избавляет от разведочного вызова «а есть ли уже такая»: агент просто задаёт нужное значение.
/// Новая переменная заводится с нулём и сразу получает выражение (оно может быть и формулой).
/// Возвращает вычисленное значение и признак создания.
/// </summary>
public Task<(double Value, bool Created)> SetOrCreateVariableAsync(
string name, string expression, string? note = null, bool external = false, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
RequireName(name);
if (string.IsNullOrWhiteSpace(expression))
throw new ArgumentException("Выражение не может быть пустым.", nameof(expression));
var (part, vc) = RootVariables();
var existing = vc.GetByName(name, true, false) as ksVariable;
var created = existing is null;
var v = existing ?? (vc.AddNewVariable(name, 0, note ?? "") as ksVariable
?? throw new InvalidOperationException("AddNewVariable вернул null (недопустимое имя?)."));
v.Expression = expression;
if (!created && note is not null) v.note = note;
if (external) v.external = true;
if (!part.RebuildModel())
throw new InvalidOperationException(
$"Не удалось пересчитать модель с выражением «{expression}» для '{name}' (недопустимое выражение или ссылка на несуществующую переменную?).");
return (ReadValueFresh(name), created);
}, ct);
/// <summary>Удалить переменную модели.</summary>
public Task DeleteVariableAsync(string name, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
+59 -10
View File
@@ -9,6 +9,53 @@ namespace Kompas.Mcp.Core.Query;
/// </summary>
public static class InspectionText
{
/// <summary>Разделы «паспорта» модели — чтобы не собирать и не печатать лишнее.</summary>
[Flags]
public enum ModelSections
{
None = 0,
Box = 1,
Mass = 2,
Bodies = 4,
Topology = 8,
Tree = 16,
Variables = 32,
All = Box | Mass | Bodies | Topology | Tree | Variables,
}
private static readonly (string Name, ModelSections Value)[] SectionNames =
[
("box", ModelSections.Box),
("mass", ModelSections.Mass),
("bodies", ModelSections.Bodies),
("topology", ModelSections.Topology),
("tree", ModelSections.Tree),
("variables", ModelSections.Variables),
];
/// <summary>
/// Разобрать список разделов («box,mass») в набор флагов. Пусто или «all» — все разделы.
/// Неизвестное имя — ошибка со списком допустимых (агент исправляется с первой попытки).
/// </summary>
public static ModelSections ParseSections(string? spec)
{
if (string.IsNullOrWhiteSpace(spec)) return ModelSections.All;
var result = ModelSections.None;
foreach (var raw in spec.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var name = raw.ToLowerInvariant();
if (name == "all") return ModelSections.All;
var known = SectionNames.FirstOrDefault(s => s.Name == name);
if (known.Name is null)
throw new ArgumentException(
$"Неизвестный раздел '{raw}'. Допустимо: {string.Join(" | ", SectionNames.Select(s => s.Name))} | all.");
result |= known.Value;
}
return result == ModelSections.None ? ModelSections.All : result;
}
// Габарит пустой детали КОМПАС отдаёт «пустышкой» ~2·10³⁰⁸ (double близко к максимуму).
private const double EmptyGabaritThreshold = 1e12;
@@ -50,8 +97,8 @@ public static class InspectionText
.Select(x => $"{x.Count} {x.Key}"));
}
/// <summary>Отрендерить «паспорт» модели в читаемый текст.</summary>
public static string Render(ModelDescription d)
/// <summary>Отрендерить «паспорт» модели в читаемый текст (по умолчанию — все разделы).</summary>
public static string Render(ModelDescription d, ModelSections sections = ModelSections.All)
{
var sb = new StringBuilder();
sb.AppendLine($"Документ: «{d.Name}» ({d.DocumentTypeName})");
@@ -62,19 +109,21 @@ public static class InspectionText
return sb.ToString();
}
if (d.Box is { } box)
sb.AppendLine(Inv($"Габарит (Д×Ш×В): {box.SizeX:F2} × {box.SizeY:F2} × {box.SizeZ:F2} мм"));
if (sections.HasFlag(ModelSections.Box) && d.Box is { } box)
sb.AppendLine(Inv($"Габарит (Д×Ш×В): {box.SizeX:F2} × {box.SizeY:F2} × {box.SizeZ:F2} мм [X {box.MinX:F2}…{box.MaxX:F2}; Y {box.MinY:F2}…{box.MaxY:F2}; Z {box.MinZ:F2}…{box.MaxZ:F2}]"));
if (d.Mass is { } m)
if (sections.HasFlag(ModelSections.Mass) && d.Mass is { } m)
sb.AppendLine(Inv($"МЦХ: объём {m.Volume:F1} мм³, масса {m.Mass:F4} кг, площадь {m.Area:F1} мм², центр масс ({m.CenterX:F2}, {m.CenterY:F2}, {m.CenterZ:F2})"));
sb.AppendLine($"Тела: {d.Bodies.Count}" + FormatBodies(d.Bodies));
if (sections.HasFlag(ModelSections.Bodies))
sb.AppendLine($"Тела: {d.Bodies.Count}" + FormatBodies(d.Bodies));
sb.AppendLine($"Топология: граней {d.FaceTypes.Count} ({GroupTypes(d.FaceTypes)}); " +
$"рёбер {d.EdgeTypes.Count} ({GroupTypes(d.EdgeTypes)})");
if (sections.HasFlag(ModelSections.Topology))
sb.AppendLine($"Топология: граней {d.FaceTypes.Count} ({GroupTypes(d.FaceTypes)}); " +
$"рёбер {d.EdgeTypes.Count} ({GroupTypes(d.EdgeTypes)})");
AppendTree(sb, d);
AppendVariables(sb, d.Variables);
if (sections.HasFlag(ModelSections.Tree)) AppendTree(sb, d);
if (sections.HasFlag(ModelSections.Variables)) AppendVariables(sb, d.Variables);
return sb.ToString().TrimEnd();
}
@@ -29,8 +29,11 @@ public sealed class ModelInspectionService
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
}
/// <summary>Собрать структурный «паспорт» активной детали одним проходом.</summary>
public Task<ModelDescription> DescribeModelAsync(CancellationToken ct = default)
/// <summary>Собрать структурный «паспорт» активной детали одним проходом.
/// <paramref name="sections"/> ограничивает сбор: незапрошенные разделы не читаются вовсе
/// (обход топологии по всем граням и рёбрам — самая дорогая часть).</summary>
public Task<ModelDescription> DescribeModelAsync(
InspectionText.ModelSections sections = InspectionText.ModelSections.All, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
@@ -53,17 +56,26 @@ public sealed class ModelInspectionService
DocumentTypeName = typeName,
};
var bodies = ReadBodies(part);
var (faceTypes, edgeTypes) = ReadTopology(part);
var features = ReadFeatures(part);
var variables = ReadVariables(part);
// Раздел «дерево» решает, импортирована ли модель, а для этого нужно число тел —
// поэтому тела читаем и тогда, когда печатать их не просили.
var needBodies = sections.HasFlag(InspectionText.ModelSections.Bodies)
|| sections.HasFlag(InspectionText.ModelSections.Tree);
IReadOnlyList<BodyInfo> bodies = needBodies ? ReadBodies(part) : [];
IReadOnlyList<string> faceTypes = [], edgeTypes = [];
if (sections.HasFlag(InspectionText.ModelSections.Topology))
(faceTypes, edgeTypes) = ReadTopology(part);
IReadOnlyList<FeatureInfo> features = sections.HasFlag(InspectionText.ModelSections.Tree)
? ReadFeatures(part) : [];
IReadOnlyList<VariableInfo> variables = sections.HasFlag(InspectionText.ModelSections.Variables)
? ReadVariables(part) : [];
return new ModelDescription
{
Name = name,
IsEmpty = false,
Box = box,
Mass = ReadMass(part),
Box = sections.HasFlag(InspectionText.ModelSections.Box) ? box : null,
Mass = sections.HasFlag(InspectionText.ModelSections.Mass) ? ReadMass(part) : null,
Bodies = bodies,
FaceTypes = faceTypes,
EdgeTypes = edgeTypes,
@@ -131,6 +143,30 @@ public sealed class ModelInspectionService
finally { ComHelper.Release(part); }
}, ct);
/// <summary>
/// Точка на грани с индексом <paramref name="index"/> из list_faces — середина параметрической
/// области поверхности (та же (u,v), по которой считается нормаль в DescribeFace). Мост «индекс → точка»
/// для операций API7, которые выбирают грань только по мировой точке (перемещение грани, отверстие):
/// агент даёт надёжный индекс, а координаты подбирает сервер.
/// </summary>
public Task<(double X, double Y, double Z)> FaceCenterPointAsync(int index, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
try
{
var def = FaceAt(Faces(part), index);
if (def.GetSurface() is not ksSurface surf)
throw new InvalidOperationException($"Не удалось получить поверхность грани[{index}].");
double u = (surf.GetParamUMin() + surf.GetParamUMax()) / 2;
double v = (surf.GetParamVMin() + surf.GetParamVMax()) / 2;
if (!surf.GetPoint(u, v, out double x, out double y, out double z))
throw new InvalidOperationException($"GetPoint для грани[{index}] вернул FALSE.");
return (x, y, z);
}
finally { ComHelper.Release(part); }
}, ct);
/// <summary>Детали ребра по индексу из list_edges: тип, длина, смежные грани, концевые вершины.</summary>
public Task<EdgeDetail> DescribeEdgeAsync(int index, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
@@ -0,0 +1,61 @@
using System.Runtime.Versioning;
namespace Kompas.Mcp.Core.Validation;
/// <summary>
/// Авто-валидация: дописывает к ответу мутирующей операции результат проверки построения.
/// Операция может «пройти» (<c>Create()/Update()==true</c>), оставив деталь в ошибке, поэтому
/// проверка идёт сразу после каждой операции — агенту не нужно звать validate_part отдельно.
/// Режим переключается на лету (<c>set_auto_validate</c>) или при старте
/// (<see cref="AutoValidationSettings.EnvironmentVariable"/>).
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class AutoValidation
{
private readonly ValidationService _validation;
private volatile bool _enabled;
public AutoValidation(ValidationService validation)
: this(validation, AutoValidationSettings.ReadFromEnvironment())
{
}
public AutoValidation(ValidationService validation, bool enabled)
{
_validation = validation ?? throw new ArgumentNullException(nameof(validation));
_enabled = enabled;
}
/// <summary>Включена ли авто-валидация.</summary>
public bool Enabled
{
get => _enabled;
set => _enabled = value;
}
/// <summary>
/// Дописать к сообщению об успехе результат проверки построения. При выключенном режиме и при
/// сбое самой проверки сообщение возвращается как есть: авто-валидация никогда не превращает
/// удачную операцию в ошибку.
/// </summary>
public async Task<string> AnnotateAsync(string message, CancellationToken ct = default)
{
if (!_enabled) return message;
IReadOnlyList<OperationError> errors;
try
{
errors = await _validation.ValidatePartAsync(ct).ConfigureAwait(false);
}
catch (Exception ex)
{
return $"{message} (проверка построения недоступна: {ex.Message})";
}
if (errors.Count == 0) return message + " Построение чистое.";
var lines = errors.Select(e => $"\"{e.Name}\" (код {e.ErrorCode}: {e.Description})");
return message + $" ⚠ Внимание: {errors.Count} операц. в ошибке: " + string.Join("; ", lines) +
". Результат может быть некорректным.";
}
}
@@ -0,0 +1,26 @@
namespace Kompas.Mcp.Core.Validation;
/// <summary>Разбор настройки авто-валидации из окружения (отдельно от COM — чтобы покрыть тестами).</summary>
public static class AutoValidationSettings
{
/// <summary>Имя переменной окружения, задающей режим при старте сервера.</summary>
public const string EnvironmentVariable = "KOMPAS_MCP_AUTOVALIDATE";
/// <summary>
/// Включена ли авто-валидация. По умолчанию (переменная не задана) — да.
/// Выключают значения 0 | false | off | no | disabled (регистр не важен, пробелы обрезаются).
/// </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>Прочитать режим из переменной окружения процесса.</summary>
public static bool ReadFromEnvironment()
=> ParseEnabled(Environment.GetEnvironmentVariable(EnvironmentVariable));
}
+2
View File
@@ -50,6 +50,8 @@ builder.Services.AddSingleton<ConversionService>();
builder.Services.AddSingleton<FaceEditService>();
builder.Services.AddSingleton<SolidEditService>();
builder.Services.AddSingleton<ValidationService>();
// Режим авто-валидации живёт на весь процесс: умолчание из KOMPAS_MCP_AUTOVALIDATE, переключение — set_auto_validate.
builder.Services.AddSingleton<AutoValidation>();
// MCP-сервер поверх stdio; инструменты находятся по атрибутам в этой сборке.
builder.Services
+5 -3
View File
@@ -1,13 +1,14 @@
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Assemblies;
using Kompas.Mcp.Core.Validation;
using ModelContextProtocol.Server;
namespace Kompas.Mcp.Host.Tools;
/// <summary>Инструменты работы со сборками (вставка/сопряжение компонентов).</summary>
[McpServerToolType]
public sealed class AssemblyTools(KompasSession session, AssemblyService assembly)
public sealed class AssemblyTools(KompasSession session, AssemblyService assembly, AutoValidation autoValidation)
{
[McpServerTool(Name = "assembly_add_component")]
[Description("Вставить компонент (деталь .m3d или подсборку .a3d) из файла в активную сборку. " +
@@ -22,7 +23,8 @@ public sealed class AssemblyTools(KompasSession session, AssemblyService assembl
{
await session.ConnectAsync();
var c = await assembly.AddComponentAsync(filePath, x, y, z);
return $"Компонент '{c.Name}' вставлен в сборку, индекс {c.Index}, позиция ({x}, {y}, {z}).";
return await autoValidation.AnnotateAsync(
$"Компонент '{c.Name}' вставлен в сборку, индекс {c.Index}, позиция ({x}, {y}, {z}).");
}
[McpServerTool(Name = "assembly_add_mate")]
@@ -44,6 +46,6 @@ public sealed class AssemblyTools(KompasSession session, AssemblyService assembl
var type = Mates.Parse(mateType);
var m = await assembly.AddMateAsync(type, x1, y1, z1, x2, y2, z2, value);
var valueText = Mates.RequiresValue(type) ? $", значение {value} мм" : "";
return $"Сопряжение '{type}'{valueText} наложено (valid={m.Valid}).";
return await autoValidation.AnnotateAsync($"Сопряжение '{type}'{valueText} наложено (valid={m.Valid}).");
}
}
+13 -12
View File
@@ -32,24 +32,25 @@ public sealed class DocumentTools(KompasSession session, DocumentService docs, P
return $"Открыт документ: {info.Name} ({info.Type}).";
}
[McpServerTool(Name = "document_save_as")]
[Description("Сохранить активный документ по указанному пути. Безопасно для новых документов. Расширения: .m3d (деталь), .a3d (сборка), .cdw (чертёж), .frw (фрагмент).")]
public async Task<string> SaveAs([Description(олный путь файла с расширением")] string path)
[McpServerTool(Name = "document_save")]
[Description("Сохранить активный документ. Для нового (ещё не сохранённого) документа передайте path — " +
"полный путь с расширением: .m3d (деталь), .a3d (сборка), .cdw (чертёж), .frw (фрагмент). " +
"Без path документ сохраняется туда, откуда открыт (только для уже сохранённого ранее). " +
"path также используют, чтобы сохранить копию под новым именем.")]
public async Task<string> Save(
[Description("Полный путь файла с расширением; пусто — сохранить на прежнее место")] string? path = null)
{
await session.ConnectAsync();
if (string.IsNullOrWhiteSpace(path))
{
await docs.SaveAsync();
return "Документ сохранён.";
}
await docs.SaveAsAsync(path);
return $"Сохранено: {path}";
}
[McpServerTool(Name = "document_save")]
[Description("Сохранить активный документ (для уже сохранённого ранее).")]
public async Task<string> Save()
{
await session.ConnectAsync();
await docs.SaveAsync();
return "Документ сохранён.";
}
[McpServerTool(Name = "document_close")]
[Description("Закрыть активный документ. save — сохранять ли изменения при закрытии.")]
public async Task<string> Close([Description("Сохранять изменения")] bool save = false)
+37 -21
View File
@@ -2,6 +2,7 @@ using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Editing;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
using Kompas.Mcp.Core.Validation;
using ModelContextProtocol.Server;
@@ -13,19 +14,44 @@ public sealed class EditTools(
KompasSession session,
FaceEditService faceEdit,
SolidEditService solidEdit,
ValidationService validation)
ModelInspectionService inspection,
AutoValidation autoValidation)
{
[McpServerTool(Name = "move_face")]
[Description("Прямое редактирование («Переместить грань»): сдвинуть грань детали, найденную по мировой точке " +
"(x,y,z в мм, точка должна лежать на грани), на distance мм вдоль её нормали. distance>0 — наружу " +
"(добавить материал, удлинить деталь), distance<0 — внутрь. Направление нормали подбирается автоматически. " +
"Работает и на импортированной (STEP) геометрии без дерева построения. Перед вызовом сделайте " +
"model_snapshot/list_faces и прикиньте точку на нужной грани.")]
public async Task<string> MoveFace(double x, double y, double z, double distance)
[Description("Прямое редактирование («Переместить грань»): сдвинуть грань детали на distance мм вдоль её " +
"нормали. distance>0 — наружу (добавить материал, удлинить деталь), distance<0 — внутрь; " +
"направление нормали подбирается автоматически. Грань задаётся ЛИБО faceIndex из list_faces " +
"(надёжнее), ЛИБО точкой x,y,z на грани в мировых координатах модели. " +
"Работает и на импортированной (STEP) геометрии без дерева построения.")]
public async Task<string> MoveFace(
[Description("Смещение вдоль нормали, мм (>0 — наружу, <0 — внутрь)")] double distance,
[Description("Индекс грани из list_faces")] int? faceIndex = null,
[Description("X точки на грани, мм")] double? x = null,
[Description("Y точки на грани, мм")] double? y = null,
[Description("Z точки на грани, мм")] double? z = null)
{
await session.ConnectAsync();
await faceEdit.MoveFaceAsync(x, y, z, distance);
return await WithValidation($"Грань в точке ({x}, {y}, {z}) перемещена на {distance} мм вдоль нормали.");
double px, py, pz;
string where;
if (faceIndex is { } fi)
{
(px, py, pz) = await inspection.FaceCenterPointAsync(fi);
where = $"Грань [{fi}]";
}
else if (x is { } ax && y is { } ay && z is { } az)
{
(px, py, pz) = (ax, ay, az);
where = $"Грань в точке ({ax}, {ay}, {az})";
}
else
{
throw new ArgumentException(
"Не задана грань: передайте faceIndex из list_faces (надёжнее) либо все три координаты точки на грани (x, y, z).");
}
await faceEdit.MoveFaceAsync(px, py, pz, distance);
return await autoValidation.AnnotateAsync($"{where} перемещена на {distance} мм вдоль нормали.");
}
[McpServerTool(Name = "split_solid_by_plane")]
@@ -52,7 +78,7 @@ public sealed class EditTools(
{
await session.ConnectAsync();
await solidEdit.MoveBodyAsync(bodyIndex, dx, dy, dz);
return await WithValidation($"Тело [{bodyIndex}] перемещено на ({dx}, {dy}, {dz}) мм.");
return await autoValidation.AnnotateAsync($"Тело [{bodyIndex}] перемещено на ({dx}, {dy}, {dz}) мм.");
}
[McpServerTool(Name = "boolean_union")]
@@ -63,16 +89,6 @@ public sealed class EditTools(
{
await session.ConnectAsync();
await solidEdit.UnionAllAsync();
return await WithValidation("Тела объединены.");
}
/// <summary>Дописать к сообщению результат проверки построения (операция могла «пройти», оставив ошибку).</summary>
private async Task<string> WithValidation(string baseMsg)
{
var errs = await validation.ValidatePartAsync();
if (errs.Count == 0) return baseMsg + " Построение чистое.";
var lines = errs.Select(e => $"\"{e.Name}\" (код {e.ErrorCode}: {e.Description})");
return baseMsg + $" ⚠ Внимание: {errs.Count} операц. в ошибке: " + string.Join("; ", lines) +
". Результат может быть некорректным.";
return await autoValidation.AnnotateAsync("Тела объединены.");
}
}
+217 -162
View File
@@ -1,94 +1,107 @@
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
using Kompas.Mcp.Core.Validation;
using ModelContextProtocol.Server;
namespace Kompas.Mcp.Host.Tools;
/// <summary>Инструменты формообразующих операций.</summary>
[McpServerToolType]
public sealed class FeatureTools(KompasSession session, PartModeler modeler, HoleService holes)
public sealed class FeatureTools(
KompasSession session,
PartModeler modeler,
HoleService holes,
ModelInspectionService inspection,
AutoValidation autoValidation)
{
[McpServerTool(Name = "extrude_boss")]
[Description("Выдавить эскиз, добавив материал (на глубину depth, мм). Возвращает id операции.")]
public async Task<string> ExtrudeBoss(int sketchId, double depth, [Description("Прямое направление")] bool forward = true)
{
await session.ConnectAsync();
var id = await modeler.ExtrudeAsync(sketchId, depth, cut: false, forward: forward);
return $"Выдавливание (boss) создано, id={id}.";
}
[McpServerTool(Name = "extrude_cut")]
[Description("Вырезать выдавливанием по эскизу. Для сквозного отверстия передайте throughAll=true (depth не нужен). Иначе вырез на глубину depth (мм).")]
public async Task<string> ExtrudeCut(
int sketchId,
[Description("Глубина, мм (нужна, если throughAll=false)")] double depth = 0,
[Description("Вырезать насквозь через всю деталь")] bool throughAll = false,
[McpServerTool(Name = "extrude")]
[Description("Выдавить эскиз: mode=boss — добавить материал, mode=cut — вырезать. depth — глубина в мм. " +
"Для сквозного выреза передайте throughAll=true (depth не нужен; для boss сквозной режим " +
"неприменим). forward — прямое направление; если операция ушла «не туда», повторите с " +
"forward=false. Возвращает 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)
{
await session.ConnectAsync();
var id = await modeler.ExtrudeAsync(sketchId, depth, cut: true, forward: forward, throughAll: throughAll);
return $"Вырез (cut) создан, id={id}.";
var cut = ParseCutMode(mode);
var id = await modeler.ExtrudeAsync(sketchId, depth, cut: cut, forward: forward, throughAll: throughAll);
var what = cut ? "Вырез" : "Выдавливание";
var how = throughAll ? "насквозь" : $"на {depth} мм";
return await autoValidation.AnnotateAsync($"{what} выдавливанием {how} создан(о), id={id}.");
}
[McpServerTool(Name = "revolve_boss")]
[Description("Вращать эскиз вокруг его осевой линии, добавив материал. Эскиз должен содержать профиль и осевую линию (sketch_add_axis). angle — угол в градусах (по умолчанию 360). Возвращает id операции.")]
public async Task<string> RevolveBoss(
int sketchId,
[McpServerTool(Name = "revolve")]
[Description("Вращать эскиз вокруг его осевой линии: mode=boss — добавить материал, mode=cut — вырезать. " +
"Эскиз должен содержать профиль И осевую линию (примитив type=axis в sketch_create/sketch_add). " +
"angle — угол в градусах (по умолчанию 360). Возвращает id операции.")]
public async Task<string> Revolve(
[Description("Id эскиза с профилем и осевой линией")] int sketchId,
[Description("Режим: boss (добавить материал) | cut (вырезать)")] string mode,
[Description("Угол вращения, градусы (0..360]")] double angle = 360,
[Description("Прямое направление")] bool forward = true)
{
await session.ConnectAsync();
var id = await modeler.RevolveAsync(sketchId, angle, cut: false, forward: forward);
return $"Вращение (boss) создано, id={id}.";
}
[McpServerTool(Name = "revolve_cut")]
[Description("Вырезать вращением эскиза вокруг его осевой линии. Эскиз должен содержать профиль и осевую линию (sketch_add_axis). angle — угол в градусах (по умолчанию 360). Возвращает id операции.")]
public async Task<string> RevolveCut(
int sketchId,
[Description("Угол вращения, градусы (0..360]")] double angle = 360,
[Description("Прямое направление")] bool forward = true)
{
await session.ConnectAsync();
var id = await modeler.RevolveAsync(sketchId, angle, cut: true, forward: forward);
return $"Вырез вращением (cut) создан, id={id}.";
var cut = ParseCutMode(mode);
var id = await modeler.RevolveAsync(sketchId, angle, cut: cut, forward: forward);
var what = cut ? "Вырез вращением" : "Вращение";
return await autoValidation.AnnotateAsync($"{what} на {angle}° создан(о), id={id}.");
}
[McpServerTool(Name = "fillet_edge")]
[Description("Скруглить ребро детали, выбранное по точке (мировые координаты x,y,z в мм, точка должна лежать на ребре), радиусом radius (мм). Сделайте model_snapshot, прикиньте точку на ребре. Возвращает id операции.")]
public async Task<string> FilletEdge(double x, double y, double z, double radius)
[Description("Скруглить рёбра детали радиусом radius (мм) — одной операцией. Рёбра задаются ЛИБО списком " +
"индексов edgeIndices из list_edges (предпочтительно: не нужно угадывать координаты, за один " +
"вызов скругляется хоть весь контур), ЛИБО точкой x,y,z на ребре в мировых координатах модели " +
"(запасной вариант — тогда скругляется одно ребро). Возвращает id операции.")]
public async Task<string> FilletEdge(
[Description("Радиус скругления, мм")] double radius,
[Description("Индексы рёбер из list_edges")] int[]? edgeIndices = null,
[Description("X точки на ребре, мм")] double? x = null,
[Description("Y точки на ребре, мм")] double? y = null,
[Description("Z точки на ребре, мм")] double? z = null)
{
await session.ConnectAsync();
var id = await modeler.FilletEdgeAsync(x, y, z, radius);
return $"Скругление R{radius} создано на ребре в точке ({x}, {y}, {z}), id={id}.";
if (edgeIndices is { Length: > 0 })
{
var id = await modeler.FilletEdgesAsync(edgeIndices, radius);
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);
return await autoValidation.AnnotateAsync(
$"Скругление R{radius} создано на ребре в точке ({px}, {py}, {pz}), id={byPoint}.");
}
[McpServerTool(Name = "chamfer_edge")]
[Description("Снять фаску с ребра детали, выбранного по точке (мировые координаты x,y,z в мм, точка должна лежать на ребре), катетом distance (мм). Сделайте model_snapshot, прикиньте точку на ребре. Возвращает id операции.")]
public async Task<string> ChamferEdge(double x, double y, double z, double distance)
[Description("Снять фаску с рёбер детали катетом distance (мм) — одной операцией. Рёбра задаются ЛИБО " +
"списком индексов edgeIndices из list_edges (предпочтительно), ЛИБО точкой x,y,z на ребре " +
"в мировых координатах модели (тогда обрабатывается одно ребро). Возвращает id операции.")]
public async Task<string> ChamferEdge(
[Description("Катет фаски, мм")] double distance,
[Description("Индексы рёбер из list_edges")] int[]? edgeIndices = null,
[Description("X точки на ребре, мм")] double? x = null,
[Description("Y точки на ребре, мм")] double? y = null,
[Description("Z точки на ребре, мм")] double? z = null)
{
await session.ConnectAsync();
var id = await modeler.ChamferEdgeAsync(x, y, z, distance);
return $"Фаска {distance}×{distance} создана на ребре в точке ({x}, {y}, {z}), id={id}.";
}
if (edgeIndices is { Length: > 0 })
{
var id = await modeler.ChamferEdgesAsync(edgeIndices, distance);
var count = edgeIndices.Distinct().Count();
return await autoValidation.AnnotateAsync($"Фаска {distance}×{distance} создана на {count} рёбрах, id={id}.");
}
[McpServerTool(Name = "fillet_edge_index")]
[Description("Скруглить ребро детали по индексу из list_edges радиусом radius (мм). Надёжнее выбора по точке. Возвращает id операции.")]
public async Task<string> FilletEdgeIndex([Description("Индекс ребра из list_edges")] int edgeIndex, double radius)
{
await session.ConnectAsync();
var id = await modeler.FilletEdgeIndexAsync(edgeIndex, radius);
return $"Скругление R{radius} создано на ребре [{edgeIndex}], id={id}.";
}
[McpServerTool(Name = "chamfer_edge_index")]
[Description("Снять фаску с ребра детали по индексу из list_edges катетом distance (мм). Надёжнее выбора по точке. Возвращает id операции.")]
public async Task<string> ChamferEdgeIndex([Description("Индекс ребра из list_edges")] int edgeIndex, double distance)
{
await session.ConnectAsync();
var id = await modeler.ChamferEdgeIndexAsync(edgeIndex, distance);
return $"Фаска {distance}×{distance} создана на ребре [{edgeIndex}], id={id}.";
var (px, py, pz) = RequirePoint(x, y, z, "ребре", "edgeIndices");
var byPoint = await modeler.ChamferEdgeAsync(px, py, pz, distance);
return await autoValidation.AnnotateAsync(
$"Фаска {distance}×{distance} создана на ребре в точке ({px}, {py}, {pz}), id={byPoint}.");
}
[McpServerTool(Name = "shell")]
@@ -101,7 +114,7 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler, Hol
await session.ConnectAsync();
var id = await modeler.ShellAsync(faceIndices, thickness, outward);
var removed = faceIndices?.Distinct().Count() ?? 0;
return $"Оболочка толщиной {thickness} мм создана (удалено граней: {removed}), id={id}.";
return await autoValidation.AnnotateAsync($"Оболочка толщиной {thickness} мм создана (удалено граней: {removed}), id={id}.");
}
[McpServerTool(Name = "rib")]
@@ -110,7 +123,7 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler, Hol
{
await session.ConnectAsync();
var id = await modeler.RibAsync(sketchId, thickness, side, symmetric, angle);
return $"Ребро жёсткости толщиной {thickness} мм создано (side={side}), id={id}.";
return await autoValidation.AnnotateAsync($"Ребро жёсткости толщиной {thickness} мм создано (side={side}), id={id}.");
}
[McpServerTool(Name = "sweep")]
@@ -119,11 +132,12 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler, Hol
{
await session.ConnectAsync();
var id = await modeler.SweepAsync(profileSketchId, pathSketchId);
return $"Кинематическая операция создана (профиль {profileSketchId} по траектории {pathSketchId}), id={id}.";
return await autoValidation.AnnotateAsync(
$"Кинематическая операция создана (профиль {profileSketchId} по траектории {pathSketchId}), id={id}.");
}
[McpServerTool(Name = "loft")]
[Description("Операция по сечениям (loft): построить сплошное тело по нескольким закрытым эскизам-сечениям (sketchIds, минимум 2, в порядке списка). Сечения обычно на параллельных плоскостях — используйте sketch_create_on_offset_plane для сечений на разной высоте. closed=true — замкнуть сечения в кольцо. Возвращает id операции.")]
[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)
@@ -131,71 +145,123 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler, Hol
await session.ConnectAsync();
var id = await modeler.LoftAsync(sketchIds, closed);
var sections = sketchIds?.Distinct().Count() ?? 0;
return $"Операция по сечениям создана ({sections} сечений), id={id}.";
return await autoValidation.AnnotateAsync($"Операция по сечениям создана ({sections} сечений), id={id}.");
}
[McpServerTool(Name = "linear_pattern")]
[Description("Линейный массив: размножить ранее созданные операции featureIds (их id из extrude/revolve/fillet/...) вдоль координатной оси axis (X|Y|Z). count — количество экземпляров ВСЕГО, включая исходный (>=2). step — шаг между соседними экземплярами (мм). geometric=true — быстрое геометрическое копирование (может не строиться на скруглениях/фасках кривых поверхностей). Возвращает id операции.")]
public async Task<string> LinearPattern(
[Description("Id операций-источников (из ранее созданных операций)")] int[] featureIds,
[Description("Координатная ось направления: X|Y|Z")] string axis,
[McpServerTool(Name = "pattern")]
[Description("Массив операций: размножить ранее созданные операции featureIds (их id из extrude/revolve/" +
"fillet_edge/hole/…). kind=linear — вдоль координатной оси axis с шагом step в мм; " +
"kind=circular — вокруг оси axis с угловым шагом step в градусах (например 90 для 4 шт). " +
"count — количество экземпляров ВСЕГО, включая исходный (>=2). reverse — обратное направление " +
"вращения (только circular). geometric=true — быстрое геометрическое копирование (может не " +
"строиться на скруглениях/фасках кривых поверхностей). Возвращает id операции.")]
public async Task<string> Pattern(
[Description("Тип массива: linear | circular")] string kind,
[Description("Id операций-источников")] int[] featureIds,
[Description("Координатная ось (направления или вращения): X|Y|Z")] string axis,
[Description("Число экземпляров всего, включая исходный (>=2)")] int count,
[Description("Шаг между соседними экземплярами, мм")] double step,
[Description("Шаг между соседними: мм для linear, градусы для circular")] double step,
[Description("Обратное направление вращения (только circular)")] bool reverse = false,
[Description("Геометрическое копирование (быстрее)")] bool geometric = false)
{
await session.ConnectAsync();
var id = await modeler.LinearPatternAsync(featureIds, CoordinateAxes.Parse(axis), count, step, geometric);
return $"Линейный массив создан ({count} экз. вдоль {axis}, шаг {step} мм), id={id}.";
var circular = ParsePatternKind(kind);
var parsedAxis = CoordinateAxes.Parse(axis);
if (circular)
{
var id = await modeler.CircularPatternAsync(featureIds, parsedAxis, count, step, reverse, geometric);
return await autoValidation.AnnotateAsync($"Круговой массив создан ({count} экз. вокруг {axis}, шаг {step}°), id={id}.");
}
var linearId = await modeler.LinearPatternAsync(featureIds, parsedAxis, count, step, geometric);
return await autoValidation.AnnotateAsync($"Линейный массив создан ({count} экз. вдоль {axis}, шаг {step} мм), id={linearId}.");
}
[McpServerTool(Name = "circular_pattern")]
[Description("Круговой массив: размножить ранее созданные операции featureIds вокруг координатной оси axis (X|Y|Z). count — количество экземпляров ВСЕГО, включая исходный (>=2). step — угловой шаг между соседними экземплярами, градусы (например 90 для 4 шт через 90°, или 120 для 3 шт). reverse=true — обратное направление вращения. geometric=true — быстрое геометрическое копирование. Возвращает id операции.")]
public async Task<string> CircularPattern(
[Description("Id операций-источников")] int[] featureIds,
[Description("Координатная ось вращения: X|Y|Z")] string axis,
[Description("Число экземпляров всего, включая исходный (>=2)")] int count,
[Description("Угловой шаг между соседними, градусы")] double step,
[Description("Обратное направление вращения")] bool reverse = false,
[Description("Геометрическое копирование (быстрее)")] bool geometric = false)
[McpServerTool(Name = "mirror")]
[Description("Зеркальное отражение относительно координатной плоскости plane (XOY|XOZ|YOZ). " +
"Без featureIds отражается ВСЁ тело детали (оригинал + зеркало — удобно строить половину и " +
"достраивать симметрию). С featureIds отражаются только указанные операции (их оригиналы " +
"сохраняются). Возвращает id операции.")]
public async Task<string> Mirror(
[Description("Плоскость симметрии: XOY|XOZ|YOZ")] string plane,
[Description("Id отражаемых операций; пусто — отразить всё тело")] int[]? featureIds = null)
{
await session.ConnectAsync();
var id = await modeler.CircularPatternAsync(featureIds, CoordinateAxes.Parse(axis), count, step, reverse, geometric);
return $"Круговой массив создан ({count} экз. вокруг {axis}, шаг {step}°), id={id}.";
}
var basePlane = BasePlanes.Parse(plane);
[McpServerTool(Name = "mirror_operation")]
[Description("Зеркальная копия операций: отразить ранее созданные операции featureIds относительно координатной плоскости plane (XOY|XOZ|YOZ). Добавляет зеркальные копии (оригинал сохраняется). Возвращает id операции.")]
public async Task<string> MirrorOperation(
[Description("Id операций-источников")] int[] featureIds,
[Description("Плоскость симметрии: XOY|XOZ|YOZ")] string plane)
{
await session.ConnectAsync();
var id = await modeler.MirrorOperationAsync(featureIds, BasePlanes.Parse(plane));
return $"Зеркальная копия операций создана (плоскость {plane}), id={id}.";
}
if (featureIds is { Length: > 0 })
{
var id = await modeler.MirrorOperationAsync(featureIds, basePlane);
return await autoValidation.AnnotateAsync($"Зеркальная копия операций создана (плоскость {plane}), id={id}.");
}
[McpServerTool(Name = "mirror_body")]
[Description("Зеркально отразить всё тело детали относительно координатной плоскости plane (XOY|XOZ|YOZ) — получить симметричную деталь (оригинал + зеркало). Удобно для моделирования половины с последующим отражением. Возвращает id операции.")]
public async Task<string> MirrorBody(
[Description("Плоскость симметрии: XOY|XOZ|YOZ")] string plane)
{
await session.ConnectAsync();
var id = await modeler.MirrorBodyAsync(BasePlanes.Parse(plane));
return $"Тело зеркально отражено (плоскость {plane}), id={id}.";
var bodyId = await modeler.MirrorBodyAsync(basePlane);
return await autoValidation.AnnotateAsync($"Тело зеркально отражено (плоскость {plane}), id={bodyId}.");
}
[McpServerTool(Name = "hole")]
[Description("Просверлить простое цилиндрическое отверстие (API7) на грани, выбранной по мировой точке (x,y,z в мм — точка должна лежать на грани и задаёт ЦЕНТР отверстия; сделайте model_snapshot/describe_face и прикиньте точку). diameter — диаметр (мм). throughAll=true — сквозное (depth не нужен); иначе глухое на глубину depth (мм). Направление в тело подбирается автоматически. Отличие от extrude_cut: параметрическое отверстие без эскиза.")]
[Description("Просверлить параметрическое отверстие (API7) — в отличие от extrude cut, без эскиза. " +
"Грань и центр отверстия задаются ЛИБО faceIndex из list_faces (сверлится в центре грани), " +
"ЛИБО точкой x,y,z на грани в мировых координатах модели (точка = центр отверстия). " +
"type: simple (простое цилиндрическое) | counterbore (с ЦЕКОВКОЙ — цилиндрическое уширение под " +
"винт с цилиндрической головкой, нужны spotfaceDiameter > diameter и spotfaceDepth) | " +
"countersink (с ЗЕНКОВКОЙ — конической, под потайную головку, нужны sinkDiameter > diameter и " +
"sinkAngle в (0;180), типично 90) | conic (коническое, нужен conicAngle в (0;180); diameter — " +
"диаметр у дна). throughAll=true — сквозное (depth не нужен), иначе глухое на глубину depth " +
"(для counterbore depth должен быть больше spotfaceDepth). Направление в тело подбирается " +
"автоматически по факту убыли материала.")]
public async Task<string> Hole(
double x, double y, double z,
[Description("Диаметр отверстия, мм")] double diameter,
[Description("Глубина, мм (нужна, если throughAll=false)")] double depth = 0,
[Description("Сквозное отверстие через всё тело")] bool throughAll = false)
[Description("Диаметр отверстия, мм (для conic — у дна)")] double diameter,
[Description("Тип: simple | counterbore | countersink | conic")] string type = "simple",
[Description("Индекс грани из list_faces — отверстие в центре грани")] int? faceIndex = null,
[Description("X точки-центра на грани, мм")] double? x = null,
[Description("Y точки-центра на грани, мм")] double? y = null,
[Description("Z точки-центра на грани, мм")] double? z = null,
[Description("Глубина, мм (если throughAll=false)")] double depth = 0,
[Description("Сквозное отверстие через всё тело")] bool throughAll = false,
[Description("Диаметр цековки, мм (> diameter) — для counterbore")] double? spotfaceDiameter = null,
[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)
{
await session.ConnectAsync();
await holes.HoleAsync(x, y, z, diameter, depth, throughAll);
var kind = throughAll ? "сквозное" : $"глухое на {depth} мм";
return $"Отверстие Ø{diameter} ({kind}) создано на грани в точке ({x}, {y}, {z}).";
var kind = (type ?? "simple").Trim().ToLowerInvariant();
var (px, py, pz) = await ResolveFacePointAsync(faceIndex, x, y, z);
var where = faceIndex is { } fi ? $"в центре грани [{fi}]" : $"в точке ({px}, {py}, {pz})";
var how = throughAll ? "сквозное" : $"глухое на {depth} мм";
switch (kind)
{
case "simple":
await holes.HoleAsync(px, py, pz, diameter, depth, throughAll);
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);
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);
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);
return await autoValidation.AnnotateAsync(
$"Коническое отверстие Ø{diameter} (угол {conicA}°, {how}) создано {where}.");
default:
throw new ArgumentException(
$"Неизвестный тип отверстия '{type}'. Допустимо: simple | counterbore | countersink | conic.");
}
}
[McpServerTool(Name = "draft")]
@@ -209,62 +275,51 @@ public sealed class FeatureTools(KompasSession session, PartModeler modeler, Hol
await session.ConnectAsync();
var id = await modeler.DraftAsync(faceIndices, BasePlanes.Parse(neutralPlane), angle, outward);
var count = faceIndices?.Distinct().Count() ?? 0;
return $"Уклон {angle}° создан на {count} гранях (нейтральная плоскость {neutralPlane}), id={id}.";
}
[McpServerTool(Name = "hole_counterbore")]
[Description("Отверстие с ЦЕКОВКОЙ (цилиндрическое уширение сверху — под винт с цилиндрической головкой) на грани в точке (x,y,z — центр). diameter — диаметр отверстия (мм). depth/throughAll — как у hole (для глухого depth должен быть больше spotfaceDepth). spotfaceDiameter — диаметр цековки (мм, больше diameter), spotfaceDepth — глубина цековки (мм). Направление в тело — авто.")]
public async Task<string> HoleCounterbore(
double x, double y, double z,
[Description("Диаметр отверстия, мм")] double diameter,
[Description("Диаметр цековки, мм (> diameter)")] double spotfaceDiameter,
[Description("Глубина цековки, мм")] double spotfaceDepth,
[Description("Глубина отверстия, мм (если throughAll=false)")] double depth = 0,
[Description("Сквозное отверстие")] bool throughAll = false)
{
await session.ConnectAsync();
await holes.CounterboreHoleAsync(x, y, z, diameter, depth, throughAll, spotfaceDiameter, spotfaceDepth);
var kind = throughAll ? "сквозное" : $"глубина {depth} мм";
return $"Отверстие с цековкой Ø{diameter} (цековка Ø{spotfaceDiameter}×{spotfaceDepth}, {kind}) создано в точке ({x}, {y}, {z}).";
}
[McpServerTool(Name = "hole_countersink")]
[Description("Отверстие с ЗЕНКОВКОЙ (коническое уширение сверху — под винт с потайной головкой) на грани в точке (x,y,z — центр). diameter — диаметр отверстия (мм). depth/throughAll — как у hole. sinkDiameter — диаметр зенковки (мм, больше diameter), sinkAngle — угол конуса зенковки (градусы, строго 0 < sinkAngle < 180; типично 90). Направление в тело — авто.")]
public async Task<string> HoleCountersink(
double x, double y, double z,
[Description("Диаметр отверстия, мм")] double diameter,
[Description("Диаметр зенковки, мм (> diameter)")] double sinkDiameter,
[Description("Угол конуса зенковки, градусы (0..180, типично 90)")] double sinkAngle,
[Description("Глубина отверстия, мм (если throughAll=false)")] double depth = 0,
[Description("Сквозное отверстие")] bool throughAll = false)
{
await session.ConnectAsync();
await holes.CountersinkHoleAsync(x, y, z, diameter, depth, throughAll, sinkDiameter, sinkAngle);
var kind = throughAll ? "сквозное" : $"глубина {depth} мм";
return $"Отверстие с зенковкой Ø{diameter} (зенковка Ø{sinkDiameter}, угол {sinkAngle}°, {kind}) создано в точке ({x}, {y}, {z}).";
}
[McpServerTool(Name = "hole_conic")]
[Description("Коническое (конусное) отверстие на грани в точке (x,y,z — центр). diameter — диаметр у дна (мм). conicAngle — угол конуса (градусы, строго 0 < conicAngle < 180); отверстие расширяется к поверхности. depth/throughAll — как у hole. Направление в тело — авто.")]
public async Task<string> HoleConic(
double x, double y, double z,
[Description("Диаметр у дна, мм")] double diameter,
[Description("Угол конуса, градусы (0 < conicAngle < 180)")] double conicAngle,
[Description("Глубина отверстия, мм (если throughAll=false)")] double depth = 0,
[Description("Сквозное отверстие")] bool throughAll = false)
{
await session.ConnectAsync();
await holes.ConicHoleAsync(x, y, z, diameter, depth, throughAll, conicAngle);
var kind = throughAll ? "сквозное" : $"глубина {depth} мм";
return $"Коническое отверстие Ø{diameter} (угол {conicAngle}°, {kind}) создано в точке ({x}, {y}, {z}).";
return await autoValidation.AnnotateAsync(
$"Уклон {angle}° создан на {count} гранях (нейтральная плоскость {neutralPlane}), id={id}.");
}
[McpServerTool(Name = "rebuild")]
[Description("Перестроить активный документ.")]
[Description("Перестроить активный документ. Нужен перед чтением МЦХ после правок переменных/геометрии.")]
public async Task<string> Rebuild()
{
await session.ConnectAsync();
await modeler.RebuildAsync();
return "Документ перестроен.";
return await autoValidation.AnnotateAsync("Документ перестроен.");
}
/// <summary>Разобрать режим boss|cut для выдавливания/вращения.</summary>
private static bool ParseCutMode(string? mode) => (mode ?? "").Trim().ToLowerInvariant() switch
{
"boss" or "add" => false,
"cut" => true,
_ => throw new ArgumentException($"Неизвестный режим '{mode}'. Допустимо: boss (добавить материал) | cut (вырезать)."),
};
/// <summary>Разобрать тип массива: true — круговой, false — линейный.</summary>
private static bool ParsePatternKind(string? kind) => (kind ?? "").Trim().ToLowerInvariant() switch
{
"linear" => false,
"circular" => true,
_ => throw new ArgumentException($"Неизвестный тип массива '{kind}'. Допустимо: linear | circular."),
};
/// <summary>Точка выбора объекта: либо все три координаты, либо понятная ошибка с альтернативой.</summary>
private static (double X, double Y, double Z) RequirePoint(double? x, double? y, double? z, string on, string indexParam)
{
if (x is { } px && y is { } py && z is { } pz) return (px, py, pz);
throw new ArgumentException(
$"Не задан объект: передайте {indexParam} (надёжнее) либо все три координаты точки на {on}: x, y, z.");
}
/// <summary>Грань по индексу превращаем в точку на ней (центр параметрической области):
/// операции API7 выбирают грань только по мировой точке, а индекс из list_faces надёжнее.</summary>
private async Task<(double X, double Y, double Z)> ResolveFacePointAsync(int? faceIndex, double? x, double? y, double? z)
{
if (faceIndex is { } fi) return await inspection.FaceCenterPointAsync(fi);
return RequirePoint(x, y, z, "грани", "faceIndex");
}
private static double Required(double? value, string name, string kind)
=> value ?? throw new ArgumentException($"type={kind} требует параметр {name}.");
}
+11 -33
View File
@@ -14,14 +14,19 @@ public sealed class InspectionTools(KompasSession session, ModelInspectionServic
{
[McpServerTool(Name = "describe_model")]
[Description("ПРЕДПОЧТИТЕЛЬНЫЙ способ «увидеть» активную деталь — структурный паспорт одним вызовом: " +
"габарит, МЦХ, тела, сводка топологии (грани/рёбра по типам), дерево построения с параметрами и переменные. " +
"Вызывайте ПЕРЕД model_snapshot: это точнее снимка и дешевле по контексту. Снимок берите только для " +
"визуально-пространственных вопросов, на которые паспорт не отвечает.")]
public async Task<string> DescribeModel()
"габарит (размеры и координаты), МЦХ (объём, масса, площадь, центр масс), тела, сводка " +
"топологии (грани/рёбра по типам), дерево построения с параметрами и переменные. " +
"sections сужает ответ до нужных разделов: box | mass | bodies | topology | tree | variables " +
"(через запятую; пусто = всё). Незапрошенные разделы не читаются вовсе — на тяжёлой модели " +
"«box,mass» заметно быстрее полного паспорта. Вызывайте ПЕРЕД model_snapshot: точнее снимка " +
"и дешевле по контексту; снимок — только для визуально-пространственных вопросов.")]
public async Task<string> DescribeModel(
[Description("Разделы через запятую: box | mass | bodies | topology | tree | variables; пусто — все")] string? sections = null)
{
await session.ConnectAsync();
var d = await inspection.DescribeModelAsync();
return InspectionText.Render(d);
var wanted = InspectionText.ParseSections(sections);
var d = await inspection.DescribeModelAsync(wanted);
return InspectionText.Render(d, wanted);
}
[McpServerTool(Name = "list_features")]
@@ -64,33 +69,6 @@ public sealed class InspectionTools(KompasSession session, ModelInspectionServic
return $"Переменных: {vs.Count}\n" + string.Join("\n", lines);
}
[McpServerTool(Name = "describe_face")]
[Description("Детали грани по индексу из list_faces: тип, площадь, нормаль в центре, радиус (для цилиндра) " +
"и число ограничивающих рёбер. Углубляет list_faces без угадывания координат.")]
public async Task<string> DescribeFace([Description("Индекс грани из list_faces")] int faceIndex)
{
await session.ConnectAsync();
var f = await inspection.DescribeFaceAsync(faceIndex);
var sb = Inv($"Грань [{f.Index}]: {f.Type}, площадь {f.Area:F2} мм², ограничивающих рёбер: {f.EdgeCount}");
if (f.Radius is { } r) sb += Inv($", радиус {r:F2} мм");
if (f.Normal is { } n) sb += Inv($", нормаль ({n.X:F3}, {n.Y:F3}, {n.Z:F3})");
return sb;
}
[McpServerTool(Name = "describe_edge")]
[Description("Детали ребра по индексу из list_edges: тип, длина, две смежные грани и координаты концевых вершин.")]
public async Task<string> DescribeEdge([Description("Индекс ребра из list_edges")] int edgeIndex)
{
await session.ConnectAsync();
var e = await inspection.DescribeEdgeAsync(edgeIndex);
var sb = Inv($"Ребро [{e.Index}]: {e.Type}, длина {e.Length:F2} мм");
if (e.AdjacentFace1 is not null || e.AdjacentFace2 is not null)
sb += $", смежные грани: {e.AdjacentFace1 ?? "?"} / {e.AdjacentFace2 ?? "?"}";
if (e.Start is { } s && e.End is { } en)
sb += Inv($", концы ({s.X:F2}, {s.Y:F2}, {s.Z:F2}) → ({en.X:F2}, {en.Y:F2}, {en.Z:F2})");
return sb;
}
[McpServerTool(Name = "measure")]
[Description("Измерить расстояние и угол между двумя объектами активной детали. kind1/kind2: face|edge|vertex; " +
"индексы — из list_faces/list_edges. Возвращает минимальное расстояние (мм) и угол (°, если применим).")]
+37 -24
View File
@@ -7,18 +7,8 @@ namespace Kompas.Mcp.Host.Tools;
/// <summary>Инструменты запросов к модели.</summary>
[McpServerToolType]
public sealed class QueryTools(KompasSession session, QueryService query)
public sealed class QueryTools(KompasSession session, QueryService query, ModelInspectionService inspection)
{
[McpServerTool(Name = "get_part_info")]
[Description("Массово-центровочные характеристики активной детали: объём (мм³), масса (кг), площадь (мм²) и центр масс. Полезно для числовой проверки построения.")]
public async Task<string> GetPartInfo()
{
await session.ConnectAsync();
var i = await query.GetPartInfoAsync();
return $"Объём: {i.Volume:F2} мм³; масса: {i.Mass:F4} кг; площадь: {i.Area:F2} мм²; " +
$"центр масс: ({i.CenterX:F2}, {i.CenterY:F2}, {i.CenterZ:F2}).";
}
[McpServerTool(Name = "list_components")]
[Description("Перечислить компоненты верхнего уровня активной сборки: индекс, имя, обозначение, признак детали и габарит (мм). Индекс/имя — для последующего извлечения или осмотра детали.")]
public async Task<string> ListComponents()
@@ -36,10 +26,25 @@ public sealed class QueryTools(KompasSession session, QueryService query)
}
[McpServerTool(Name = "list_faces")]
[Description("Перечислить грани активной детали: индекс, тип поверхности (plane/cylinder/cone/sphere/torus/nurbs) и площадь (мм²). Индекс используйте в sketch_create_on_face_index. Удобно для надёжного выбора грани без угадывания координат.")]
public async Task<string> ListFaces()
[Description("Грани активной детали. Без index — список: индекс, тип поверхности " +
"(plane/cylinder/cone/sphere/torus/nurbs) и площадь (мм²). С index — подробности этой грани: " +
"площадь, нормаль в центре, радиус (для цилиндра) и число ограничивающих рёбер. " +
"Индекс — надёжный способ адресовать грань (sketch_create, hole, move_face, shell, draft) " +
"без угадывания координат.")]
public async Task<string> ListFaces(
[Description("Индекс грани для подробностей; пусто — список всех граней")] int? index = null)
{
await session.ConnectAsync();
if (index is { } i)
{
var f = await inspection.DescribeFaceAsync(i);
var detail = Inv($"Грань [{f.Index}]: {f.Type}, площадь {f.Area:F2} мм², ограничивающих рёбер: {f.EdgeCount}");
if (f.Radius is { } r) detail += Inv($", радиус {r:F2} мм");
if (f.Normal is { } n) detail += Inv($", нормаль ({n.X:F3}, {n.Y:F3}, {n.Z:F3})");
return detail;
}
var faces = await query.ListFacesAsync();
if (faces.Count == 0) return "Граней нет (модель пуста?).";
var lines = faces.Select(f => $" [{f.Index}] {f.Type}, площадь {f.Area:F2} мм²");
@@ -47,23 +52,31 @@ public sealed class QueryTools(KompasSession session, QueryService query)
}
[McpServerTool(Name = "list_edges")]
[Description("Перечислить рёбра активной детали: индекс, тип кривой (line/circle/arc/ellipse/nurbs) и длину (мм). Индекс используйте в fillet_edge_index / chamfer_edge_index для надёжного выбора ребра без угадывания координат.")]
public async Task<string> ListEdges()
[Description("Рёбра активной детали. Без index — список: индекс, тип кривой " +
"(line/circle/arc/ellipse/nurbs) и длина (мм). С index — подробности этого ребра: длина, " +
"две смежные грани и координаты концевых вершин. Индексы передавайте в fillet_edge / " +
"chamfer_edge — это надёжнее выбора по точке.")]
public async Task<string> ListEdges(
[Description("Индекс ребра для подробностей; пусто — список всех рёбер")] int? index = null)
{
await session.ConnectAsync();
if (index is { } i)
{
var e = await inspection.DescribeEdgeAsync(i);
var detail = Inv($"Ребро [{e.Index}]: {e.Type}, длина {e.Length:F2} мм");
if (e.AdjacentFace1 is not null || e.AdjacentFace2 is not null)
detail += $", смежные грани: {e.AdjacentFace1 ?? "?"} / {e.AdjacentFace2 ?? "?"}";
if (e.Start is { } s && e.End is { } en)
detail += Inv($", концы ({s.X:F2}, {s.Y:F2}, {s.Z:F2}) → ({en.X:F2}, {en.Y:F2}, {en.Z:F2})");
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);
}
[McpServerTool(Name = "get_bounding_box")]
[Description("Габариты активной детали: координаты габаритного параллелепипеда (мм) и его размеры. Помогает оценить протяжённость модели и подобрать точки для выбора граней/рёбер.")]
public async Task<string> GetBoundingBox()
{
await session.ConnectAsync();
var b = await query.GetBoundingBoxAsync();
return $"Габарит: X [{b.MinX:F2}; {b.MaxX:F2}], Y [{b.MinY:F2}; {b.MaxY:F2}], Z [{b.MinZ:F2}; {b.MaxZ:F2}]; " +
$"размеры (Д×Ш×В): {b.SizeX:F2} × {b.SizeY:F2} × {b.SizeZ:F2} мм.";
}
private static string Inv(FormattableString fs) => FormattableString.Invariant(fs);
}
+80 -137
View File
@@ -5,160 +5,103 @@ using ModelContextProtocol.Server;
namespace Kompas.Mcp.Host.Tools;
/// <summary>Инструменты построения эскизов в активной детали.</summary>
/// <summary>Инструменты эскиза: создание на плоскости/грани и наполнение примитивами.</summary>
[McpServerToolType]
public sealed class SketchTools(KompasSession session, PartModeler modeler)
{
[McpServerTool(Name = "sketch_create")]
[Description("Создать эскиз на базовой плоскости (XOY, XOZ или YOZ) активной детали и открыть его на редактирование. Возвращает id эскиза для последующих sketch_add_* и операций.")]
public async Task<string> Create([Description("Плоскость: XOY, XOZ или YOZ")] string plane)
[Description("Создать эскиз активной детали и (при передаче entities) сразу построить в нём геометрию — " +
"весь контур одним вызовом. Основание эскиза задаётся ОДНИМ из способов: plane (базовая " +
"плоскость XOY|XOZ|YOZ, при offset≠0 — смещённая от неё на offset мм); faceIndex (грань из " +
"list_faces — предпочтительный способ строить «на грани» уже созданного тела); либо точка x,y,z " +
"на грани в мировых координатах модели (запасной вариант, когда индекс не подходит). " +
"entities — список примитивов, autoClose=true закрывает эскиз сразу, чтобы можно было звать " +
"extrude/revolve. Без entities эскиз остаётся открытым — наполняйте его через sketch_add. " +
"Возвращает id эскиза.")]
public async Task<string> Create(
[Description("Базовая плоскость: XOY | XOZ | YOZ")] string? plane = null,
[Description("Индекс грани из list_faces (надёжнее выбора по точке)")] int? faceIndex = null,
[Description("X точки на грани, мировые координаты модели, мм")] double? x = null,
[Description("Y точки на грани, мм")] double? y = null,
[Description("Z точки на грани, мм")] double? z = null,
[Description("Смещение от базовой плоскости, мм (только вместе с plane)")] double offset = 0,
[Description("Прямое направление смещения от базовой плоскости")] bool offsetDirection = true,
[Description("Примитивы эскиза: список объектов {type, points, centerX, ...}")] SketchEntityInput[]? entities = null,
[Description("Закрыть эскиз после построения entities (нужно перед операцией)")] bool autoClose = true)
{
await session.ConnectAsync();
var id = await modeler.OpenSketchAsync(BasePlanes.Parse(plane));
return $"Эскиз создан, id={id}. Добавьте геометрию (sketch_add_*) и завершите sketch_close.";
var parsed = SketchEntityInputs.Parse(entities);
int id;
string on;
if (faceIndex is { } fi)
{
id = await modeler.OpenSketchOnFaceIndexAsync(fi);
on = $"грани [{fi}]";
}
else if (x is { } px && y is { } py && z is { } pz)
{
id = await modeler.OpenSketchOnFaceAsync(px, py, pz);
on = $"грани в точке ({px}, {py}, {pz})";
}
else if (!string.IsNullOrWhiteSpace(plane))
{
var basePlane = BasePlanes.Parse(plane);
if (offset == 0)
{
id = await modeler.OpenSketchAsync(basePlane);
on = $"плоскости {plane}";
}
else
{
id = await modeler.OpenSketchOnOffsetPlaneAsync(basePlane, offset, offsetDirection);
on = $"плоскости {plane}, смещение {offset} мм";
}
}
else
{
throw new ArgumentException(
"Не задано основание эскиза: передайте plane (XOY|XOZ|YOZ), либо faceIndex из list_faces, " +
"либо все три координаты точки на грани (x, y, z).");
}
if (parsed.Count == 0)
return $"Эскиз id={id} создан на {on} и открыт. Добавьте геометрию через sketch_add.";
// Пакет строится за один заход на COM-поток; autoClose закрывает эскиз там же.
await modeler.AddEntitiesAsync(id, parsed, autoClose);
var state = autoClose ? "закрыт — можно строить операцию" : "остался открытым";
return $"Эскиз id={id} создан на {on}, добавлено примитивов: {parsed.Count}, {state}.";
}
[McpServerTool(Name = "sketch_create_on_face")]
[Description("Создать эскиз на плоской грани детали, выбранной по точке (мировые координаты модели x,y,z в мм), и открыть его на редактирование. Используйте для построения «на грани» поверх уже созданной геометрии: сделайте model_snapshot, прикиньте координаты точки на нужной грани, затем вызовите этот инструмент. Возвращает id эскиза.")]
public async Task<string> CreateOnFace(double x, double y, double z)
[McpServerTool(Name = "sketch_add")]
[Description("Добавить примитивы в ОТКРЫТЫЙ эскиз (id из sketch_create) — список за один вызов. " +
"Нужен, когда эскиз наполняется в несколько приёмов; если геометрия известна сразу, " +
"передавайте её прямо в sketch_create. autoClose=true завершает эскиз после добавления. " +
"Каждый элемент entities: {type, points, centerX, centerY, radius, ...} — обязательные поля " +
"зависят от type.")]
public async Task<string> Add(
[Description("Id эскиза из sketch_create")] int sketchId,
[Description("Примитивы эскиза: список объектов {type, points, centerX, ...}")] SketchEntityInput[] entities,
[Description("Закрыть эскиз после добавления")] bool autoClose = false)
{
await session.ConnectAsync();
var id = await modeler.OpenSketchOnFaceAsync(x, y, z);
return $"Эскиз создан на грани в точке ({x}, {y}, {z}), id={id}. Координаты геометрии — в локальной плоскости эскиза.";
}
var parsed = SketchEntityInputs.Parse(entities);
if (parsed.Count == 0)
throw new ArgumentException("Список entities пуст — нечего добавлять.");
[McpServerTool(Name = "sketch_create_on_offset_plane")]
[Description("Создать эскиз на плоскости, смещённой от базовой (XOY|XOZ|YOZ) на offset мм, и открыть на редактирование. Позволяет строить геометрию на произвольной высоте/смещении (например сечения для операции loft). direction — направление смещения от базовой плоскости. Возвращает id эскиза.")]
public async Task<string> CreateOnOffsetPlane(
[Description("Базовая плоскость: XOY | XOZ | YOZ")] string basePlane,
[Description("Смещение от базовой плоскости, мм")] double offset,
[Description("Прямое направление смещения")] bool direction = true)
{
await session.ConnectAsync();
var id = await modeler.OpenSketchOnOffsetPlaneAsync(BasePlanes.Parse(basePlane), offset, direction);
return $"Эскиз создан на смещённой плоскости {basePlane}+{offset}, id={id}.";
}
[McpServerTool(Name = "sketch_add_circle")]
[Description("Добавить окружность в открытый эскиз. Координаты — в плоскости эскиза, мм.")]
public async Task<string> AddCircle(int sketchId, double centerX, double centerY, double radius)
{
await session.ConnectAsync();
await modeler.AddCircleAsync(sketchId, centerX, centerY, radius);
return "Окружность добавлена.";
}
[McpServerTool(Name = "sketch_add_line")]
[Description("Добавить отрезок в открытый эскиз (две точки, мм).")]
public async Task<string> AddLine(int sketchId, double x1, double y1, double x2, double y2)
{
await session.ConnectAsync();
await modeler.AddLineAsync(sketchId, x1, y1, x2, y2);
return "Отрезок добавлен.";
}
[McpServerTool(Name = "sketch_create_on_face_index")]
[Description("Создать эскиз на грани детали, заданной индексом из list_faces, и открыть на редактирование. Надёжнее выбора по точке (не нужно угадывать координаты). Возвращает id эскиза.")]
public async Task<string> CreateOnFaceIndex([Description("Индекс грани из list_faces")] int faceIndex)
{
await session.ConnectAsync();
var id = await modeler.OpenSketchOnFaceIndexAsync(faceIndex);
return $"Эскиз создан на грани [{faceIndex}], id={id}.";
}
[McpServerTool(Name = "sketch_add_axis")]
[Description("Добавить осевую линию (ось вращения) в открытый эскиз — нужна для revolve_boss/revolve_cut. Две точки в плоскости эскиза, мм.")]
public async Task<string> AddAxis(int sketchId, double x1, double y1, double x2, double y2)
{
await session.ConnectAsync();
await modeler.AddAxisAsync(sketchId, x1, y1, x2, y2);
return "Осевая линия добавлена.";
}
[McpServerTool(Name = "sketch_add_rectangle")]
[Description("Добавить прямоугольник по двум противоположным углам в открытый эскиз (мм).")]
public async Task<string> AddRectangle(int sketchId, double x1, double y1, double x2, double y2)
{
await session.ConnectAsync();
await modeler.AddRectangleAsync(sketchId, x1, y1, x2, y2);
return "Прямоугольник добавлен.";
await modeler.AddEntitiesAsync(sketchId, parsed, autoClose);
var state = autoClose ? " Эскиз закрыт." : "";
return $"В эскиз id={sketchId} добавлено примитивов: {parsed.Count}.{state}";
}
[McpServerTool(Name = "sketch_close")]
[Description("Завершить редактирование эскиза (перед операцией выдавливания).")]
[Description("Завершить редактирование эскиза. Нужен, только если эскиз наполнялся с autoClose=false — " +
"перед формообразующей операцией эскиз должен быть закрыт.")]
public async Task<string> Close(int sketchId)
{
await session.ConnectAsync();
await modeler.CloseSketchAsync(sketchId);
return "Эскиз закрыт.";
}
[McpServerTool(Name = "sketch_add_arc_3points")]
[Description("Добавить дугу по трём точкам (начало, промежуточная точка на дуге, конец) в открытый эскиз. Координаты — в плоскости эскиза, мм.")]
public async Task<string> AddArc3Points(int sketchId, double x1, double y1, double x2, double y2, double x3, double y3)
{
await session.ConnectAsync();
await modeler.AddArc3PointsAsync(sketchId, x1, y1, x2, y2, x3, y3);
return "Дуга по 3 точкам добавлена.";
}
[McpServerTool(Name = "sketch_add_arc")]
[Description("Добавить дугу по центру, радиусу и углам (в градусах, от оси X) в открытый эскиз. counterClockwise=true — против часовой стрелки, false — по часовой. Координаты центра — в плоскости эскиза, мм.")]
public async Task<string> AddArc(int sketchId, double centerX, double centerY, double radius, double startAngle, double endAngle, bool counterClockwise = true)
{
await session.ConnectAsync();
await modeler.AddArcByAngleAsync(sketchId, centerX, centerY, radius, startAngle, endAngle, counterClockwise);
return "Дуга добавлена.";
}
[McpServerTool(Name = "sketch_add_ellipse")]
[Description("Добавить эллипс в открытый эскиз: центр, большая полуось semiMajor, малая полуось semiMinor (мм), угол наклона большой оси к X (градусы, по умолчанию 0).")]
public async Task<string> AddEllipse(int sketchId, double centerX, double centerY, double semiMajor, double semiMinor, double angle = 0)
{
await session.ConnectAsync();
await modeler.AddEllipseAsync(sketchId, centerX, centerY, semiMajor, semiMinor, angle);
return "Эллипс добавлен.";
}
[McpServerTool(Name = "sketch_add_polyline")]
[Description("Добавить ломаную — цепочку прямых отрезков по списку точек — в открытый эскиз. closed=true замыкает последнюю точку с первой (тогда нужно минимум 3 точки). Координаты — в плоскости эскиза, мм.")]
public async Task<string> AddPolyline(int sketchId, SketchPoint[] points, bool closed = false)
{
await session.ConnectAsync();
await modeler.AddPolylineAsync(sketchId, Map(points), closed);
return $"Ломаная из {points.Length} точек добавлена{(closed ? " (замкнута)" : "")}.";
}
[McpServerTool(Name = "sketch_add_polygon")]
[Description("Добавить правильный многоугольник в открытый эскиз: центр, vertexCount вершин (>=3), radius (мм). inscribed=true — вершины лежат на окружности радиуса (вписанный в окружность); false — стороны касаются окружности (описанный). angle — поворот первой вершины (градусы, по умолчанию 0).")]
public async Task<string> AddPolygon(int sketchId, double centerX, double centerY, int vertexCount, double radius, bool inscribed = true, double angle = 0)
{
await session.ConnectAsync();
await modeler.AddPolygonAsync(sketchId, centerX, centerY, vertexCount, radius, inscribed, angle);
return $"Правильный многоугольник ({vertexCount} вершин) добавлен.";
}
[McpServerTool(Name = "sketch_add_spline")]
[Description("Добавить сплайн (кубический NURBS) через список точек в открытый эскиз. closed=true замыкает кривую. Координаты — в плоскости эскиза, мм.")]
public async Task<string> AddSpline(int sketchId, SketchPoint[] points, bool closed = false)
{
await session.ConnectAsync();
await modeler.AddSplineAsync(sketchId, Map(points), closed);
return $"Сплайн по {points.Length} точкам добавлен{(closed ? " (замкнут)" : "")}.";
}
[McpServerTool(Name = "sketch_add_point")]
[Description("Добавить точку в открытый эскиз (опорная точка для построений). Координаты — в плоскости эскиза, мм.")]
public async Task<string> AddPoint(int sketchId, double x, double y)
{
await session.ConnectAsync();
await modeler.AddPointAsync(sketchId, x, y);
return "Точка добавлена.";
}
private static IReadOnlyList<(double x, double y)> Map(SketchPoint[] points)
{
ArgumentNullException.ThrowIfNull(points);
return points.Select(p => (p.X, p.Y)).ToList();
return $"Эскиз id={sketchId} закрыт.";
}
}
+22 -3
View File
@@ -1,13 +1,14 @@
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Validation;
using ModelContextProtocol.Server;
namespace Kompas.Mcp.Host.Tools;
/// <summary>Системные инструменты: подключение, статус, видимость окна.</summary>
[McpServerToolType]
public sealed class SystemTools(KompasSession session)
public sealed class SystemTools(KompasSession session, AutoValidation autoValidation)
{
[McpServerTool(Name = "kompas_connect")]
[Description("Подключиться к КОМПАС-3D: присоединиться к запущенному экземпляру или запустить новый, показать окно. Возвращает версию и редакцию.")]
@@ -34,9 +35,27 @@ public sealed class SystemTools(KompasSession session)
return "Сервер не может обратиться к КОМПАС. " + ex.Message;
}
return session.IsConnected && session.LastInfo is { } i
var validation = autoValidation.Enabled
? "Авто-валидация включена: операции сами дописывают итог проверки построения."
: "Авто-валидация выключена: проверяйте построение вызовом validate_part.";
return (session.IsConnected && session.LastInfo is { } i
? $"Подключено: КОМПАС {i.Version} ({i.Edition})."
: "Не подключено. Вызовите kompas_connect.";
: "Не подключено. Вызовите kompas_connect.") + " " + validation;
}
[McpServerTool(Name = "set_auto_validate")]
[Description("Включить или выключить авто-валидацию: дописывать ли к ответу каждой мутирующей операции " +
"(выдавливание, вращение, скругление, отверстие, массив, прямое редактирование…) итог проверки " +
"построения. Включена по умолчанию — отдельный validate_part тогда не нужен. Выключайте только " +
"если проверка после каждого шага заметно замедляет работу на тяжёлой модели; тогда зовите " +
"validate_part вручную перед выдачей результата и экспортом.")]
public string SetAutoValidate([Description("true — включить, false — выключить")] bool enabled)
{
autoValidation.Enabled = enabled;
return enabled
? "Авто-валидация включена: итог проверки построения дописывается к ответу каждой операции."
: "Авто-валидация выключена: проверяйте построение вызовом validate_part вручную.";
}
[McpServerTool(Name = "kompas_set_visible")]
+17 -31
View File
@@ -1,47 +1,33 @@
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Validation;
using ModelContextProtocol.Server;
namespace Kompas.Mcp.Host.Tools;
/// <summary>Инструменты параметрики: переменные модели (создание, изменение, удаление).</summary>
/// <summary>Инструменты параметрики: переменные модели.</summary>
[McpServerToolType]
public sealed class VariableTools(KompasSession session, VariableService variables)
public sealed class VariableTools(KompasSession session, VariableService variables, AutoValidation autoValidation)
{
[McpServerTool(Name = "create_variable")]
[Description("Создать переменную модели с начальным числовым значением value. note — комментарий. external=true — внешняя (доступна для управления из сборки). Имя должно быть уникальным. Примечание: переменная управляет геометрией только в параметрической модели (где размеры эскизов связаны с именами переменных); для моделей с литеральными размерами переменная просто хранится. Используйте list_variables для просмотра. Возвращает вычисленное значение.")]
public async Task<string> CreateVariable(
string name,
[Description("Начальное числовое значение")] double value,
[McpServerTool(Name = "set_variable")]
[Description("Задать переменную модели: создаётся, если её ещё нет, иначе меняется существующая — " +
"проверять наличие заранее не нужно. expression — константа («30») или формула со ссылками " +
"на другие переменные («width*2+5»); десятичный разделитель — точка. Модель перестраивается, " +
"значение пересчитывается. note — комментарий, external=true — внешняя переменная (управляема " +
"из сборки). ВАЖНО: переменная двигает геометрию только в параметрической модели (где размеры " +
"эскизов связаны с именами переменных); при литеральных размерах значение пересчитается, " +
"а форма не изменится. Возвращает вычисленное значение.")]
public async Task<string> SetVariable(
[Description("Имя переменной")] string name,
[Description("Выражение: число или формула (width*2+5)")] string expression,
[Description("Комментарий (необязательно)")] string? note = null,
[Description("Внешняя переменная (управляема из сборки)")] bool external = false)
{
await session.ConnectAsync();
var v = await variables.CreateVariableAsync(name, value, note, external);
return $"Переменная '{name}' создана (значение {v}).";
}
[McpServerTool(Name = "set_variable")]
[Description("Задать выражение существующей переменной: константа (например «30») или формула со ссылками на другие переменные (например «width*2+5»). Модель перестраивается, значение пересчитывается. Десятичный разделитель — точка. Примечание: изменит геометрию только в параметрической модели (где размеры эскизов связаны с именами переменных); для моделей с литеральными размерами значение пересчитается, но геометрия не изменится. Возвращает вычисленное значение.")]
public async Task<string> SetVariable(
string name,
[Description("Выражение: число или формула (width*2+5)")] string expression)
{
await session.ConnectAsync();
var v = await variables.SetVariableAsync(name, expression);
return $"Переменная '{name}' = «{expression}» → {v}.";
}
[McpServerTool(Name = "set_variable_note")]
[Description("Заменить комментарий (note) существующей переменной модели. name — имя переменной, note — новый текст комментария.")]
public async Task<string> SetVariableNote(
string name,
[Description("Новый текст комментария")] string note)
{
await session.ConnectAsync();
await variables.SetVariableNoteAsync(name, note);
return $"Комментарий переменной '{name}' обновлён.";
var (value, created) = await variables.SetOrCreateVariableAsync(name, expression, note, external);
var what = created ? "создана" : "изменена";
return await autoValidation.AnnotateAsync($"Переменная '{name}' {what}: «{expression}» → {value}.");
}
[McpServerTool(Name = "delete_variable")]
@@ -0,0 +1,30 @@
using Kompas.Mcp.Core.Validation;
namespace Kompas.Mcp.Tests;
/// <summary>Режим авто-валидации из окружения: по умолчанию включён, выключается явными «нет».</summary>
[Trait("Category", "Unit")]
public sealed class AutoValidationSettingsTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("1")]
[InlineData("true")]
[InlineData("on")]
[InlineData("что угодно")]
public void Enabled_by_default(string? value)
=> Assert.True(AutoValidationSettings.ParseEnabled(value));
[Theory]
[InlineData("0")]
[InlineData("false")]
[InlineData("FALSE")]
[InlineData("Off")]
[InlineData("no")]
[InlineData("disabled")]
[InlineData(" 0 ")]
public void Disabled_by_explicit_negatives(string value)
=> Assert.False(AutoValidationSettings.ParseEnabled(value));
}
@@ -0,0 +1,180 @@
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>
/// Интеграция: механики, на которых держатся слитые инструменты — пакет примитивов эскиза
/// за один заход, мост «индекс грани → точка на ней», скругление/фаска пачкой рёбер и upsert
/// переменной. Каждая цепочка проверяется на живом КОМПАС: слияние инструментов не должно
/// менять результат построения.
/// </summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class MergedToolPathsTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PartModeler _modeler;
private readonly QueryService _query;
private readonly ModelInspectionService _inspection;
private readonly VariableService _variables;
public MergedToolPathsTests(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);
_inspection = new ModelInspectionService(fx.Session, fx.Dispatcher);
_variables = new VariableService(fx.Session, fx.Dispatcher);
}
private static SketchEntity Rect(double x1, double y1, double x2, double y2) => new()
{
Kind = SketchEntityKind.Rectangle,
Points = [(x1, y1), (x2, y2)],
};
private static SketchEntity Circle(double cx, double cy, double r) => new()
{
Kind = SketchEntityKind.Circle,
CenterX = cx, CenterY = cy, Radius = r,
};
[Fact]
public async Task Batch_builds_the_whole_contour_in_one_call()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Плита 40×20 с четырьмя отверстиями Ø4 — раньше это было 6 вызовов, теперь один.
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
var added = await _modeler.AddEntitiesAsync(s,
[
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);
await _modeler.ExtrudeAsync(s, depth: 10);
await _modeler.RebuildAsync();
// Объём плиты минус четыре цилиндра.
var expected = (40 * 20 - 4 * Math.PI * 4) * 10;
var v = (await _query.GetPartInfoAsync()).Volume;
Assert.InRange(v, expected * 0.98, expected * 1.02);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Batch_reports_the_failing_position()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
var bad = new SketchEntity { Kind = SketchEntityKind.Circle, CenterX = 0, CenterY = 0, Radius = -1 };
var ex = await Assert.ThrowsAnyAsync<Exception>(
() => _modeler.AddEntitiesAsync(s, [Rect(0, 0, 10, 10), Circle(2, 2, 1), bad]));
Assert.Contains("[2]", ex.Message, StringComparison.Ordinal);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Face_center_point_lands_on_the_face()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddEntitiesAsync(s, [Rect(0, 0, 40, 20)], autoClose: true);
await _modeler.ExtrudeAsync(s, depth: 10);
await _modeler.RebuildAsync();
// Мост «индекс → точка»: точка обязана лежать на грани, иначе выбор по ней провалится.
var faces = await _query.ListFacesAsync();
Assert.NotEmpty(faces);
foreach (var f in faces)
{
var (x, y, z) = await _inspection.FaceCenterPointAsync(f.Index);
Assert.True(double.IsFinite(x) && double.IsFinite(y) && double.IsFinite(z),
$"грань [{f.Index}]: точка не вычислена");
// Точка внутри габарита детали (с допуском на округление).
Assert.InRange(x, -0.01, 40.01);
Assert.InRange(y, -0.01, 20.01);
Assert.InRange(z, -0.01, 10.01);
}
// И по такой точке эскиз на грани действительно создаётся.
var top = faces.First(f => f.Type == "plane");
var (px, py, pz) = await _inspection.FaceCenterPointAsync(top.Index);
var onFace = await _modeler.OpenSketchOnFaceAsync(px, py, pz);
await _modeler.CloseSketchAsync(onFace);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Fillet_and_chamfer_take_several_edges_at_once()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddEntitiesAsync(s, [Rect(0, 0, 40, 20)], autoClose: true);
await _modeler.ExtrudeAsync(s, depth: 10);
await _modeler.RebuildAsync();
var before = (await _query.GetPartInfoAsync()).Volume;
var vertical = (await _query.ListEdgesAsync()).Where(e => e.Type == "line").Take(4).Select(e => e.Index).ToList();
Assert.Equal(4, vertical.Count);
// Одна операция на все рёбра: материал убывает, дерево не растёт на четыре узла.
var featuresBefore = (await _inspection.ListFeaturesAsync()).Count;
await _modeler.FilletEdgesAsync(vertical, radius: 2);
await _modeler.RebuildAsync();
var after = (await _query.GetPartInfoAsync()).Volume;
var featuresAfter = (await _inspection.ListFeaturesAsync()).Count;
Assert.True(after < before, $"объём не убыл: {before} → {after}");
Assert.Equal(featuresBefore + 1, featuresAfter);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Set_variable_creates_then_updates()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddEntitiesAsync(s, [Rect(0, 0, 40, 20)], autoClose: true);
await _modeler.ExtrudeAsync(s, depth: 10);
var (created, wasCreated) = await _variables.SetOrCreateVariableAsync("width", "40", "ширина плиты");
Assert.True(wasCreated);
Assert.Equal(40, created, 3);
// Повторный вызов — уже изменение, и формула со ссылкой считается.
var (updated, wasCreatedAgain) = await _variables.SetOrCreateVariableAsync("width", "50");
Assert.False(wasCreatedAgain);
Assert.Equal(50, updated, 3);
var (derived, _) = await _variables.SetOrCreateVariableAsync("height", "width/2");
Assert.Equal(25, derived, 3);
var vars = await _inspection.ListVariablesAsync();
Assert.Contains(vars, v => v.Name == "width" && Math.Abs(v.Value - 50) < 1e-6);
Assert.Contains(vars, v => v.Name == "height");
}
finally { await _docs.CloseAsync(save: false); }
}
}
@@ -0,0 +1,71 @@
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests;
/// <summary>Разделы «паспорта» модели: разбор списка и фильтрация рендера.</summary>
[Trait("Category", "Unit")]
public sealed class ModelSectionsTests
{
private static ModelDescription Sample() => new()
{
Name = "Деталь",
DocumentTypeName = "деталь",
IsEmpty = false,
Box = new BoundingBox { MinX = 0, MinY = 0, MinZ = 0, MaxX = 40, MaxY = 20, MaxZ = 15 },
Mass = new PartInfo { Volume = 12000, Mass = 0.0942, Area = 3400, CenterX = 20, CenterY = 10, CenterZ = 7.5 },
Bodies = [new BodyInfo { Index = 0, IsSolid = true, FaceCount = 6 }],
FaceTypes = ["plane", "plane", "cylinder"],
EdgeTypes = ["line", "circle"],
Features = [new FeatureInfo { Index = 0, Name = "Эскиз:1", Kind = "sketch", Detail = "" }],
Imported = false,
Variables = [new VariableInfo { Name = "width", Expression = "40", Value = 40, External = false, Information = false }],
};
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("all")]
[InlineData("box,mass,bodies,topology,tree,variables")]
public void All_sections_by_default(string? spec)
=> Assert.Equal(InspectionText.ModelSections.All, InspectionText.ParseSections(spec));
[Fact]
public void Subset_is_parsed_case_and_separator_tolerant()
{
var parsed = InspectionText.ParseSections(" Box; mass ");
Assert.Equal(InspectionText.ModelSections.Box | InspectionText.ModelSections.Mass, parsed);
}
[Fact]
public void Unknown_section_lists_the_allowed_ones()
{
var ex = Assert.Throws<ArgumentException>(() => InspectionText.ParseSections("box,колбаса"));
Assert.Contains("колбаса", ex.Message, StringComparison.Ordinal);
Assert.Contains("topology", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Render_keeps_only_requested_sections()
{
var text = InspectionText.Render(Sample(), InspectionText.ModelSections.Box | InspectionText.ModelSections.Mass);
Assert.Contains("Габарит", text, StringComparison.Ordinal);
Assert.Contains("МЦХ", text, StringComparison.Ordinal);
Assert.DoesNotContain("Топология", text, StringComparison.Ordinal);
Assert.DoesNotContain("Дерево построения", text, StringComparison.Ordinal);
Assert.DoesNotContain("Переменные", text, StringComparison.Ordinal);
}
[Fact]
public void Box_section_carries_coordinates_not_only_sizes()
{
// get_bounding_box слит в describe_model — координаты габарита не должны потеряться.
var text = InspectionText.Render(Sample(), InspectionText.ModelSections.Box);
Assert.Contains("40.00 × 20.00 × 15.00 мм", text, StringComparison.Ordinal);
Assert.Contains("X 0.00…40.00", text, StringComparison.Ordinal);
Assert.Contains("Z 0.00…15.00", text, StringComparison.Ordinal);
}
}
+25 -1
View File
@@ -30,7 +30,31 @@ public sealed class PluginSkillsTests
public void Skill_has_no_stale_tool_count(string file)
{
var text = File.ReadAllText(file);
Assert.DoesNotContain("46 инструментов", text, StringComparison.Ordinal);
string[] staleCounts = ["46 инструментов", "83 инструмента", "84 инструмента"];
foreach (var stale in staleCounts)
Assert.DoesNotContain(stale, text, StringComparison.Ordinal);
}
[Theory]
[MemberData(nameof(SkillFiles))]
public void Skill_does_not_reference_merged_away_tools(string file)
{
var text = File.ReadAllText(file);
// Инструменты, слитые в общие: навык, зовущий их по имени, стоит агенту потерянного хода.
string[] gone =
[
"sketch_add_", "sketch_create_on_face", "sketch_create_on_offset_plane",
"extrude_boss", "extrude_cut", "revolve_boss", "revolve_cut",
"fillet_edge_index", "chamfer_edge_index",
"hole_counterbore", "hole_countersink", "hole_conic",
"linear_pattern", "circular_pattern", "mirror_operation", "mirror_body",
"document_save_as", "create_variable", "set_variable_note",
"get_part_info", "get_bounding_box", "describe_face", "describe_edge",
];
foreach (var name in gone)
Assert.DoesNotContain(name, text, StringComparison.Ordinal);
}
[Fact]
@@ -0,0 +1,121 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Разбор пакета примитивов эскиза. Схема слитого инструмента не может запретить неверную
/// комбинацию полей — её ловит этот разбор, и сообщение должно называть недостающий параметр
/// и позицию в списке, иначе агент не исправится с первой попытки.
/// </summary>
[Trait("Category", "Unit")]
public sealed class SketchEntityInputsTests
{
private static SketchEntityInput Circle() => new()
{
Type = "circle", CenterX = 0, CenterY = 0, Radius = 5,
};
[Fact]
public void Empty_input_gives_empty_list()
{
Assert.Empty(SketchEntityInputs.Parse(null));
Assert.Empty(SketchEntityInputs.Parse([]));
}
[Theory]
[InlineData("line", SketchEntityKind.Line)]
[InlineData("LINE", SketchEntityKind.Line)]
[InlineData(" rect ", SketchEntityKind.Rectangle)]
[InlineData("rectangle", SketchEntityKind.Rectangle)]
[InlineData("arc3", SketchEntityKind.Arc3Points)]
[InlineData("arc3points", SketchEntityKind.Arc3Points)]
[InlineData("axis", SketchEntityKind.Axis)]
public void Type_names_and_aliases_are_recognized(string type, SketchEntityKind expected)
{
var e = new SketchEntityInput
{
Type = type,
Points = [new SketchPoint(0, 0), new SketchPoint(10, 10), new SketchPoint(20, 0)],
};
Assert.Equal(expected, SketchEntityInputs.Parse([e])[0].Kind);
}
[Fact]
public void Unknown_type_lists_the_allowed_ones()
{
var e = new SketchEntityInput { Type = "spiral" };
var ex = Assert.Throws<ArgumentException>(() => SketchEntityInputs.Parse([e]));
Assert.Contains("spiral", ex.Message, StringComparison.Ordinal);
Assert.Contains("polyline", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Error_names_the_position_in_the_batch()
{
var bad = new SketchEntityInput { Type = "circle", CenterX = 0, CenterY = 0 }; // без radius
var ex = Assert.Throws<ArgumentException>(() => SketchEntityInputs.Parse([Circle(), Circle(), bad]));
Assert.Contains("entities[2]", ex.Message, StringComparison.Ordinal);
Assert.Contains("radius", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Line_requires_two_points()
{
var e = new SketchEntityInput { Type = "line", Points = [new SketchPoint(0, 0)] };
var ex = Assert.Throws<ArgumentException>(() => SketchEntityInputs.Parse([e]));
Assert.Contains("points", ex.Message, StringComparison.Ordinal);
Assert.Contains("минимум 2", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Arc_requires_center_radius_and_angles()
{
var e = new SketchEntityInput { Type = "arc", CenterX = 0, CenterY = 0, Radius = 10, StartAngle = 0 };
var ex = Assert.Throws<ArgumentException>(() => SketchEntityInputs.Parse([e]));
Assert.Contains("endAngle", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Polygon_requires_vertex_count()
{
var e = new SketchEntityInput { Type = "polygon", CenterX = 0, CenterY = 0, Radius = 10 };
var ex = Assert.Throws<ArgumentException>(() => SketchEntityInputs.Parse([e]));
Assert.Contains("vertexCount", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Defaults_are_applied_for_omitted_flags()
{
var e = new SketchEntityInput { Type = "arc", CenterX = 1, CenterY = 2, Radius = 3, StartAngle = 0, EndAngle = 90 };
var parsed = SketchEntityInputs.Parse([e])[0];
Assert.True(parsed.CounterClockwise); // умолчание — против часовой
Assert.True(parsed.Inscribed);
Assert.False(parsed.Closed);
Assert.Equal(0, parsed.Angle);
}
[Fact]
public void Points_keep_their_order()
{
var e = new SketchEntityInput
{
Type = "polyline",
Points = [new SketchPoint(0, 0), new SketchPoint(10, 0), new SketchPoint(10, 5)],
Closed = true,
};
var parsed = SketchEntityInputs.Parse([e])[0];
Assert.True(parsed.Closed);
Assert.Equal(3, parsed.Points.Count);
Assert.Equal((10.0, 5.0), parsed.Points[2]);
}
}