Инструменты были нарезаны по способу вызова, а не по смыслу: четыре отверстия, двойники *_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:
@@ -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`) →
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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-тест
|
||||
|
||||
@@ -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 и сборки покрыты частично.** Чертёж: стандартные виды, штамп, формат листа, размеры,
|
||||
шероховатость, текст, выноски, техтребования. Сопряжения: совпадение и расстояние.
|
||||
|
||||
|
||||
@@ -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 (катет слишком велик для ребра?).");
|
||||
|
||||
@@ -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,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(() =>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("Тела объединены.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}.");
|
||||
}
|
||||
|
||||
@@ -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. Возвращает минимальное расстояние (мм) и угол (°, если применим).")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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} закрыт.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user