Итерация 1: удаление операции, выравнивание надписи, отбор рёбер
По протоколу регресс-прогона (46 вызовов против 90 в прошлом): feature_delete — ksDocument3D.DeleteObject + RebuildDocument, зависимые уходят каскадом. Неудачная проба больше не требует пересобирать документ (у субагента таких кругов было три). align/vAlign у примитива text: сервер строит, замеряет и сдвигает надпись сам. Ловушка, стоившая двух заходов: ksMoveObj и ksDeleteObj по ссылке от ksConvertTextToCurve возвращают успех и не делают ничего — двигать надо по ссылке ТЕКСТА, а габарит после сдвига считать арифметикой (по текстовой ссылке рамка идёт по ячейке). Поймано тестом, который сверяет заявленный габарит с выдавленной геометрией. list_edges: отбор по типу и координатному окну + концы рёбер в списке. Так адресуется нижний контур под фаску, который иначе не выбрать: индексов не отфильтровать, точек не угадать. Навык: wf меряет пропорцию крупно (1.0→1.25 = +29 %), но квантован ступенями — точную ширину набирают height; прирост каймы указан НА СТОРОНУ и зависит от начертания, а не от шрифта; добавлено предупреждение, что primitive(subtract) режет наехавшие буквы.
This commit is contained in:
@@ -30,7 +30,7 @@ description: Внутренняя методика доработки самог
|
||||
|
||||
## Правила формы инструмента (каталог оптимизирован под агента)
|
||||
|
||||
Каталог сознательно сжат с 84 инструментов до 56 — экономится не только контекст, но и число
|
||||
Каталог сознательно сжат с 84 инструментов до 57 — экономится не только контекст, но и число
|
||||
вызовов, а каждый лишний вызов это лишний шанс сбиться. Держи форму:
|
||||
|
||||
- **Инструмент = смысл операции, вариант = параметр.** `extrude(mode=boss|cut)`,
|
||||
|
||||
@@ -226,6 +226,20 @@
|
||||
same call on the *converted* reference returns the glyphs (19.97 mm, matching the extruded solid to
|
||||
0.05 mm). Returns 0 rather than throwing when the object has no box — treat as «unknown», don't fail
|
||||
the primitive. Test: `SketchTextTests.Reported_glyph_box_matches_the_extruded_geometry`.
|
||||
- **Moving a text after it was measured (`align`/`vAlign`, verified live)**: `ksDocument2D.ksMoveObj(ref, dx, dy)`
|
||||
works **only through the TEXT reference** (the one `ksTextEx` returned). Called with the curves reference
|
||||
from `ksConvertTextToCurve` it returns success and moves nothing — the sketch geometry stays put, which
|
||||
the extrusion then proves. `ksDeleteObj` behaves the same way: success, and the curves are still there, so
|
||||
«insert → measure → delete → re-insert» silently leaves TWO strings in the sketch. After a move, do not
|
||||
re-read the box through either reference: the text reference reports the *cell*, so shift the measured
|
||||
glyph box arithmetically instead. Test: `TextAlignmentTests`.
|
||||
- **Deleting a feature (`feature_delete`, verified live)**: `ksDocument3D.DeleteObject(ksEntity)` → TRUE,
|
||||
then `RebuildDocument()`. Dependents cascade: deleting a sketch removes the extrusion built on it. A
|
||||
sketch still in edit mode must be `EndEdit`-ed first. Test: `FeatureDeleteTests`.
|
||||
- **Edge endpoints for filtering (`list_edges` window, verified live)**: `ksEdgeDefinition.GetVertex(bool)`
|
||||
→ `ksVertexDefinition.GetPoint(out x, out y, out z)` gives both ends in world mm; some curves have no
|
||||
vertices (returns null/throws) — treat as «unknown», don't fail the listing. That is what makes
|
||||
«all edges at z=0» addressable on a body with hundreds of edges.
|
||||
- **Feature names in the tree (`name` on every mutating tool, verified live)**: API5 `ksEntity.name` is
|
||||
read/write and must be assigned **before `Create()`** — `PartModeler.NewEntity(part, type, name)` does it
|
||||
for every entity, sketches included. API7 objects created outside `ksPart` (`IElementaryBody` from
|
||||
|
||||
@@ -39,7 +39,7 @@ validated live. Also packaged as the Claude Code plugin `kompas` (no release pub
|
||||
The full tool catalog (by group) lives in [`README.md`](README.md) §«Инструменты» — the single
|
||||
source of truth; do not duplicate it here.
|
||||
|
||||
**The catalog is shaped for the agent, not for the SDK** (56 tools, down from 84): a tool names the
|
||||
**The catalog is shaped for the agent, not for the SDK** (57 tools, down from 84): a tool names the
|
||||
*operation*, the variant is a parameter (`extrude(mode=boss|cut)`, `hole(type=…)`, `pattern(kind=…)`);
|
||||
an object is picked by index **or** point through one tool (no `*_index` twins); list parameters
|
||||
(`entities[]`, `edgeIndices[]`) run in a single STA hop; and every mutating op appends its own build-check
|
||||
@@ -65,13 +65,12 @@ a *general* tool with tests, and describe the method in the `kompas-3d` skill.
|
||||
arc/angular/rough dimensions (needs its own spike — `ILineDimension` has no `BaseObject`).
|
||||
- **Assembly**: mate types beyond `coincidence`/`distance` — `parallel`, `perpendicular`, `concentric`,
|
||||
`angle`, `tangency` (enum values exist in `MateType.cs`, not verified live).
|
||||
- **Editing the tree** (raised by the `cad-engineer` regression run): there is no `feature_delete` /
|
||||
`sketch_update`, so any fit-up of a sketch means `document_close(all)` + rebuilding from scratch —
|
||||
four full rounds in that run. `IFeature7.Delete()` exists and `ksDocument3D.DeleteObject` is the API5
|
||||
counterpart; neither is verified live yet.
|
||||
- **Addressing a subset of edges/faces**: on a body with hundreds of edges `list_edges` is unusable by
|
||||
answer size and there is no filter (by operation, by bounding window). Points now work in a batch
|
||||
(`fillet_edge(points=[…])`), but discovery still has no cheap path.
|
||||
- **Re-editing an operation**: `feature_delete` now removes a sketch or feature (dependents go with it),
|
||||
but there is still no `sketch_update` — changing one dimension means deleting the sketch and rebuilding
|
||||
everything downstream. Re-entering `ksSketchDefinition.BeginEdit` on an existing sketch is unexplored.
|
||||
- **`model_snapshot` takes no view** — raised by both regression runs: the image comes from whatever
|
||||
orientation the window happens to have, so a flat part cannot be checked from the front. Look at
|
||||
the projection API (`ksDocument3D`/`IViewProjectionCollection`) before adding a `view` parameter.
|
||||
- **Known caveat:** boss/cut direction on a *selected face* depends on the face-normal orientation —
|
||||
`forward` may need flipping. Pick the direction from the `list_faces(index=…)` normal or a snapshot
|
||||
before building.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**MCP-сервер для управления CAD-системой КОМПАС-3D (АСКОН) языковой моделью.**
|
||||
|
||||
Сервер — COM-клиент КОМПАС-3D: подключается к установленному КОМПАС и даёт LLM 56 инструментов для
|
||||
Сервер — COM-клиент КОМПАС-3D: подключается к установленному КОМПАС и даёт LLM 57 инструментов для
|
||||
создания документов, построения 3D-деталей (эскиз → формообразующая операция), работы со сборками и
|
||||
чертежами, импорта/экспорта STEP, прямого редактирования импортированной B-rep, а также структурного
|
||||
осмотра модели (`describe_model` — дерево операций, тела, топология, переменные, МЦХ одним вызовом)
|
||||
@@ -68,7 +68,7 @@ kompas-mcp.exe --version # версия бинаря, КОМПАС для эт
|
||||
| System | `kompas_connect`, `kompas_status`, `kompas_set_visible`, `set_auto_validate` |
|
||||
| Documents | `document_create`, `document_open`, `document_save`, `document_close`, `document_active`, `set_part_info` |
|
||||
| Sketch | `sketch_create`, `sketch_add`, `sketch_close` |
|
||||
| Features | `extrude`, `revolve`, `primitive`, `fillet_edge`, `chamfer_edge`, `shell`, `rib`, `sweep`, `loft`, `pattern`, `mirror`, `hole`, `draft`, `rebuild` |
|
||||
| Features | `extrude`, `revolve`, `primitive`, `fillet_edge`, `chamfer_edge`, `shell`, `rib`, `sweep`, `loft`, `pattern`, `mirror`, `hole`, `draft`, `feature_delete`, `rebuild` |
|
||||
| Edit | `move_face`, `split_solid_by_plane`, `move_body`, `boolean_union` |
|
||||
| Inspection | `describe_model`, `list_features`, `list_bodies`, `list_variables`, `measure` |
|
||||
| Vision | `model_snapshot` (PNG-снимок; сначала — `describe_model`) |
|
||||
@@ -83,7 +83,8 @@ kompas-mcp.exe --version # версия бинаря, КОМПАС для эт
|
||||
(`extrude(mode=boss|cut)`, `hole(type=simple|counterbore|countersink|conic)`,
|
||||
`pattern(kind=linear|circular)`), а объект — либо индексом из `list_faces`/`list_edges`,
|
||||
либо точкой в мировых координатах (рёбра под скругление/фаску — и списком точек `points[]`,
|
||||
чтобы N углов давали одну операцию). Эскиз строится списком примитивов за один вызов:
|
||||
чтобы N углов давали одну операцию). `list_edges` отбирает рёбра по типу и координатному окну —
|
||||
так адресуют «весь нижний контур» на теле, где рёбер сотни. Эскиз строится списком примитивов за один вызов:
|
||||
`sketch_create(plane|faceIndex, entities[])`. У каждой операции есть `name` — имя узла в дереве
|
||||
построения; без него дерево состоит из безликих «Эскиз:1», «Элемент выдавливания:2».
|
||||
|
||||
@@ -95,7 +96,8 @@ result=new|union|subtract|intersect)` строит тело по размера
|
||||
Примитивы эскиза: `line`, `circle`, `rectangle`, `arc`, `arc3points`, `ellipse`, `polyline`,
|
||||
`polygon`, `spline`, `point`, `axis`, `text`. Надпись (`type=text` с `text`, `height`, `fontName`)
|
||||
сразу переводится в кривые, поэтому её можно выдавливать — так делаются логотипы и гравировка;
|
||||
в ответе возвращается габарит глифов (и отдельно ширина ячейки строки), по нему надпись выравнивают. Параметры
|
||||
в ответе возвращается габарит глифов (и отдельно ширина ячейки строки), а `align`/`vAlign` кладут
|
||||
надпись центром или краем ровно в заданную точку. Параметры
|
||||
`extrude(thinThickness, thinSide)` превращают контур в стенку заданной толщины: `outward`
|
||||
наращивает её наружу и даёт ободок-эквидистанту вокруг контура (например, подложку под надпись).
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ MCP-сервер даёт **общие** операции КОМПАС (эски
|
||||
|
||||
- *Система/документы:* `kompas_connect`, `kompas_set_visible`, `kompas_status`, `set_auto_validate`; `document_create|open|save|close|active` (`document_save(path)` — сохранить новый документ или копию, без `path` — на прежнее место); `set_part_info(name, marking)` — наименование и обозначение детали.
|
||||
- *Эскиз (три инструмента вместо шестнадцати):* `sketch_create` — основание задаётся `plane` (+`offset`) ЛИБО `faceIndex` ЛИБО точкой `x,y,z`, а геометрия сразу списком `entities[{type: line|circle|rectangle|arc|arc3points|ellipse|polyline|polygon|spline|point|axis|text, …}]`; `sketch_add` — дополнить открытый эскиз; `sketch_close`.
|
||||
- *Формообразующие:* `primitive(kind=block|cylinder|sphere|cone, result=new|union|subtract|intersect)` — тело по размерам БЕЗ эскиза, `extrude(mode=boss|cut)`, `revolve(mode=boss|cut)`, `fillet_edge`/`chamfer_edge` (одной операцией на все рёбра: список `edgeIndices` ЛИБО список `points[{x,y,z}]`, либо одна точка), `shell`, `rib`, `sweep`, `loft`, `draft`, `rebuild`. У всех — `name`: имя операции в дереве построения.
|
||||
- *Формообразующие:* `primitive(kind=block|cylinder|sphere|cone, result=new|union|subtract|intersect)` — тело по размерам БЕЗ эскиза, `extrude(mode=boss|cut)`, `revolve(mode=boss|cut)`, `fillet_edge`/`chamfer_edge` (одной операцией на все рёбра: список `edgeIndices` ЛИБО список `points[{x,y,z}]`, либо одна точка), `shell`, `rib`, `sweep`, `loft`, `draft`, `feature_delete` (убрать операцию/эскиз из дерева, зависимые уходят каскадом), `rebuild`. У всех — `name`: имя операции в дереве построения.
|
||||
- *Отверстия:* `hole(type=simple|counterbore|countersink|conic)` — грань по `faceIndex` (центр грани) либо точкой.
|
||||
- *Массивы и зеркало:* `pattern(kind=linear|circular)`, `mirror` (без `featureIds` — всё тело, с ними — только эти операции).
|
||||
- *Прямое редактирование (без дерева, в т.ч. импортированная B-rep):* `move_face` (сдвинуть грань на N мм по нормали; грань — `faceIndex` или точка), `split_solid_by_plane` (рассечь тело плоскостью), `move_body` (сдвинуть тело на вектор), `boolean_union` (объединить тела).
|
||||
@@ -57,7 +57,7 @@ MCP-сервер даёт **общие** операции КОМПАС (эски
|
||||
- *Обмен:* `import_step`, `export_step`.
|
||||
- *Сборка:* `assembly_add_component`, `assembly_add_mate`.
|
||||
- *2D-чертёж:* `drawing_create_standard_views`, `drawing_fill_title_block`, `drawing_set_sheet_format`, `drawing_add_linear|diametral|radial|angular_dimension`, `drawing_add_rough`, `drawing_add_text`, `drawing_add_leader`, `drawing_set_technical_requirements`.
|
||||
- *Запрос/осмотр:* `describe_model` (структурный паспорт; `sections=box,mass,bodies,topology,tree,variables` сужает ответ и чтение), `list_faces`/`list_edges` (без `index` — список, с `index` — подробности объекта), `list_components`, `list_bodies`, `list_features`, `list_variables`, `measure`, `model_snapshot`.
|
||||
- *Запрос/осмотр:* `describe_model` (структурный паспорт; `sections=box,mass,bodies,topology,tree,variables` сужает ответ и чтение), `list_faces`/`list_edges` (без `index` — список, с `index` — подробности объекта; у `list_edges` есть отбор `type` и окно `minX…maxZ` — им адресуют то, что не выбрать по одному: `list_edges(minZ=-0.01, maxZ=0.01)` даёт весь нижний контур под фаску), `list_components`, `list_bodies`, `list_features`, `list_variables`, `measure`, `model_snapshot`.
|
||||
- *Проверка:* `validate_part` — нужен, только если авто-проверка выключена (см. правило 2).
|
||||
|
||||
Набор растёт от версии к версии — актуальный перечень с описаниями параметров отдаёт сам
|
||||
@@ -137,20 +137,31 @@ OldMan», «Карман под площадку», «Скругления уг
|
||||
занятый буквами (совпадает с тем, что получится после выдавливания); *ширина ячейки* — шаг строки
|
||||
с боковыми просветами, она больше на 1–2 % у наборных шрифтов (Zilla Slab, Bevan) и на 7–9 % у
|
||||
скриптовых (Lobster, Pacifico). Вписываешь надпись в поле — считай по габариту, не по ячейке.
|
||||
- **Выравнивание — параметрами, а не пересчётом.** `align: left|center|right` и
|
||||
`vAlign: baseline|bottom|middle|top` трактуют точку вставки как якорь: `align="center"` кладёт
|
||||
середину глифов ровно в заданный X. Сервер делает это по фактическому габариту (строит,
|
||||
замеряет, сдвигает), поэтому цикл «вставил → прочитал → переставил» агенту больше не нужен.
|
||||
`baseline` — умолчание и поведение самого КОМПАС: выносные элементы уходят ниже точки.
|
||||
- **Мерить дешевле в черновом документе.** Пока деталь пуста, габарит надписи — это габарит всей
|
||||
модели, и подбор кегля идёт без пересборки: `document_create part` → эскиз с текстом → прочитать
|
||||
метрики → закрыть без сохранения. В собранном теле (сотни граней) выделить рельеф уже нечем.
|
||||
Неудачную пробу теперь не обязательно уносить вместе с документом — `feature_delete(id)`
|
||||
убирает эскиз или операцию из дерева (зависимые уходят каскадом).
|
||||
- **`height` — это высота ПРОПИСНОЙ (cap height), а не габарит строки.** Замер на Zilla Slab при
|
||||
`height=10`: «HH» → 9.97 мм, «hd» → 10.71 (восходящие выше прописной), «Hy» → 13.24 мм
|
||||
(нижний выносной уходит на −3.27). Считаешь компоновку — закладывай выносные отдельно.
|
||||
- **Ширина линейна по `height`** (проверено до 0.01 мм) — им и подгоняй точный размер.
|
||||
**По `widthFactor` линейности НЕТ:** замер даёт +0.55 % коэффициента → +0.07 % ширины
|
||||
(КОМПАС квантует кегль). Поэтому `widthFactor` — грубая пропорция начертания, а не масштаб:
|
||||
выставил его один раз, дальше набираешь ширину высотой.
|
||||
- **Толщина каймы на острых терминалах больше, чем `thinThickness`.** Стенка `outward` обходит
|
||||
контур с миттером: на остром окончании глифа (скриптовые «n», «l») габарит растёт на
|
||||
`t / sin(θ/2)`, а не на `t` — замерено 2.11 мм при `t`=1.0 у Lobster. Считать «глиф + 2t» нельзя,
|
||||
габарит после операции надо перечитывать.
|
||||
- **`widthFactor` меняет пропорцию крупно, `height` — точно.** Замер (Lobster «OldMan», h=17):
|
||||
wf 1.00 → 71.84 мм, wf 1.25 → 92.83 (+29 %) — то есть это настоящий масштаб ширины, и без него
|
||||
скриптовый шрифт в вытянутое поле не вписать. Но КОМПАС **квантует его ступенями**: wf 1.25,
|
||||
1.26, 1.27, 1.28 дают ячейку 100.64 и ширину 92.83…93.14 (+0.3 %), а на 1.29 ячейка прыгает на
|
||||
103.34 и ширина сразу 95.49 (+2.5 %). Отсюда правило: пропорцию задаёшь `widthFactor`, точную
|
||||
ширину добираешь `height` (он линеен до 0.01 мм) — подгонка через wf топчется на ступени.
|
||||
- **Кайма на острых терминалах шире `thinThickness` — и по каждой стороне по-своему.** Стенка
|
||||
`outward` обходит контур с миттером: на остром окончании глифа габарит растёт **на сторону** на
|
||||
`t / sin(θ/2)`, а не на `t` (Lobster: слева +0.90 при t=0.9, справа +1.70 — терминал «n»).
|
||||
Величина принадлежит не шрифту, а КОНКРЕТНОМУ начертанию: растянув тот же Lobster
|
||||
(`widthFactor` 1.29), терминал заострился и правый прирост стал +1.99. Поэтому переносить число
|
||||
между вариантами нельзя — **перезамеряй габарит после каждой смены `height`/`widthFactor`**.
|
||||
- **Разрядки (трекинга) у примитива нет — её набирают пробелами.** Ширину пробела для конкретного
|
||||
шрифта и кегля вычисляют двумя пробами: «EDITION» → 38.38 мм, «E D I T I O N» → 47.11 мм,
|
||||
значит пробел 1.455 мм (Bevan, cap 5.5).
|
||||
@@ -170,6 +181,13 @@ OldMan», «Карман под площадку», «Скругления уг
|
||||
Zilla Slab «AB» (203 ребра): одиночное ребро скругляется R0.1 и R0.2, а `fillet_edge` сразу по
|
||||
всем рёбрам отказывает при обоих радиусах (соседние скругления конфликтуют). Адресно выбрать
|
||||
«только верхние» рёбра пока нечем — `list_edges` не отдаёт ни координат, ни принадлежности грани.
|
||||
- **`primitive(subtract)` режет ВСЁ, что попало в его габарит — включая уже построенные буквы.**
|
||||
В playbook'е шильдика карман вычитается после надписи, и если низ каймы букв опускается ниже
|
||||
верха кармана, карман срежет буквы, а построение останется «чистым». Перед вычитанием сверь
|
||||
числа: нижняя граница каймы (из метрик надписи + прирост каймы) должна быть выше верхней
|
||||
границы кармана. Проверка постфактум — **точное равенство убыли объёма** расчётному
|
||||
(79.5 × 10.5 × 1.0 = 834.75 против фактических 834.8 мм³): любое касание соседней геометрии
|
||||
сразу ломает это равенство, а габарит и снимок его не покажут.
|
||||
- **Углы вокруг надписи — одним `fillet_edge(points=[…])`.** На теле с сотнями рёбер список из
|
||||
`list_edges` неподъёмен, но точки углов известны из построения: передай их списком, и все рёбра
|
||||
уйдут в ОДНУ операцию дерева. Если ребра в части точек нет (типичный случай — угол «съеден»
|
||||
|
||||
@@ -571,6 +571,53 @@ public sealed partial class PartModeler
|
||||
return Register(entity);
|
||||
}, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удалить операцию или эскиз по id из дерева построения. Нужен для подгонки: неудачная проба
|
||||
/// (не тот кегль надписи, не та глубина) иначе остаётся в дереве навсегда, и единственным
|
||||
/// выходом было закрыть документ без сохранения и построить всё заново.
|
||||
/// <para>КОМПАС удаляет зависимые объекты каскадом: снося эскиз, вы сносите и операции по нему.
|
||||
/// Возвращает описание удалённого — id больше не действителен.</para>
|
||||
/// </summary>
|
||||
public Task<string> DeleteAsync(int id, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var isSketch = _sketches.TryGetValue(id, out var sketch);
|
||||
if (!isSketch && !_features.ContainsKey(id))
|
||||
throw new KeyNotFoundException(
|
||||
$"Объект с id={id} не найден среди эскизов и операций этой сессии. " +
|
||||
"Удалять можно только созданное текущей сессией (после смены документа id сбрасываются).");
|
||||
|
||||
var entity = isSketch ? sketch!.Entity : _features[id];
|
||||
var what = isSketch ? "Эскиз" : "Операция";
|
||||
var name = SafeName(entity);
|
||||
|
||||
if (isSketch) CloseSketchCore(sketch!); // из режима редактирования объект не удаляется
|
||||
|
||||
var doc3d = ActiveDoc3D();
|
||||
if (!doc3d.DeleteObject(entity))
|
||||
throw new InvalidOperationException(
|
||||
$"{what} id={id} не удалена: DeleteObject вернул FALSE. " +
|
||||
"Объект уже удалён или занят другой операцией?");
|
||||
|
||||
if (isSketch)
|
||||
{
|
||||
ReleaseCom(sketch!.Definition);
|
||||
_sketches.Remove(id);
|
||||
}
|
||||
else _features.Remove(id);
|
||||
|
||||
ReleaseCom(entity);
|
||||
doc3d.RebuildDocument();
|
||||
return name.Length == 0 ? $"{what} id={id}" : $"{what} id={id} «{name}»";
|
||||
}, ct);
|
||||
|
||||
/// <summary>Имя объекта для сообщения; у уже нежизнеспособного COM-объекта его может не быть.</summary>
|
||||
private static string SafeName(ksEntity entity)
|
||||
{
|
||||
try { return entity.name ?? ""; }
|
||||
catch (System.Runtime.InteropServices.COMException) { return ""; }
|
||||
}
|
||||
|
||||
/// <summary>Перестроить документ.</summary>
|
||||
public Task RebuildAsync(CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
|
||||
@@ -407,8 +407,33 @@ public sealed partial class PartModeler
|
||||
/// (<c>ksConvertTextToCurve</c>): пока он остаётся текстом, это оформление, и формообразующая
|
||||
/// операция его не видит. Возвращает метрики надписи — по ним агент выравнивает её, иначе
|
||||
/// фактическое место, занятое буквами, известно только после выдавливания.
|
||||
/// <para>Выравнивание КОМПАС не поддерживает (аргумент <c>align</c> у <c>ksTextEx</c> строку не
|
||||
/// двигает), поэтому при <c>align</c>/<c>vAlign</c> отличных от умолчания надпись строится,
|
||||
/// замеряется и СДВИГАЕТСЯ (<c>ksMoveObj</c>) — те же три шага, которые иначе тратил бы агент.
|
||||
/// Пересоздать её вместо сдвига нельзя: <c>ksDeleteObj</c> по ссылке от
|
||||
/// <c>ksConvertTextToCurve</c> возвращает успех, но кривые в эскизе остаются.</para>
|
||||
/// </summary>
|
||||
private TextMetrics TextCore(ksDocument2D editor, (double x, double y) at, SketchEntity e)
|
||||
{
|
||||
var (metrics, reference) = InsertTextCore(editor, at, e);
|
||||
if (e.Align == TextAlign.Left && e.VAlign == TextVAlign.Baseline) return metrics;
|
||||
if (metrics.Box is not { } box) return metrics; // без габарита выравнивать нечем
|
||||
|
||||
var (dx, dy) = TextAligns.Offset(box, at, e.Align, e.VAlign);
|
||||
if (Math.Abs(dx) < 1e-9 && Math.Abs(dy) < 1e-9) return metrics;
|
||||
|
||||
if (editor.ksMoveObj(reference, dx, dy) == 0)
|
||||
throw new InvalidOperationException(
|
||||
"Надпись не удалось выровнять: ksMoveObj отверг кривые. " +
|
||||
"Постройте её с align=left и сместите точку вставки сами.");
|
||||
|
||||
// Габарит сдвигаем арифметикой: перечитать его по ссылке нельзя — она указывает на
|
||||
// текстовый объект, а его рамка идёт по ячейке строки, а не по глифам.
|
||||
return metrics with { Box = (box.MinX + dx, box.MinY + dy, box.MaxX + dx, box.MaxY + dy) };
|
||||
}
|
||||
|
||||
/// <summary>Одна вставка надписи: метрики и ссылка на объект (нужна для перестановки).</summary>
|
||||
private (TextMetrics Metrics, int Reference) InsertTextCore(ksDocument2D editor, (double x, double y) at, SketchEntity e)
|
||||
{
|
||||
var text = e.Text ?? "";
|
||||
if (text.Length == 0)
|
||||
@@ -473,7 +498,8 @@ public sealed partial class PartModeler
|
||||
throw new InvalidOperationException("ksConvertTextToCurve вернул 0 (надпись не переведена в кривые).");
|
||||
// Габарит берём по ссылке на КРИВЫЕ, которую вернула конвертация: у исходного текстового
|
||||
// объекта рамка идёт по ячейке строки, а она шире глифов (на скриптовых шрифтах — до 8 %).
|
||||
return new TextMetrics(advance, GabaritOf(editor, curves));
|
||||
// Ссылка на ТЕКСТ, а не на кривые: правки положения принимает именно она.
|
||||
return (new TextMetrics(advance, GabaritOf(editor, curves)), reference);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -60,4 +60,10 @@ public sealed record SketchEntity
|
||||
|
||||
public bool Bold { get; init; }
|
||||
public bool Italic { get; init; }
|
||||
|
||||
/// <summary>Чем считать точку вставки по горизонтали: левым краем (умолчание), центром, правым краем.</summary>
|
||||
public TextAlign Align { get; init; } = TextAlign.Left;
|
||||
|
||||
/// <summary>Чем считать точку вставки по вертикали: базовой линией (умолчание), низом, серединой, верхом глифов.</summary>
|
||||
public TextVAlign VAlign { get; init; } = TextVAlign.Baseline;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Kompas.Mcp.Core.Modeling;
|
||||
@@ -62,11 +62,15 @@ public sealed class SketchEntityInput
|
||||
public string? FontName { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
[Description("Высота шрифта, мм — для text (примерно высота прописной с выносным элементом)")]
|
||||
[Description("Высота шрифта, мм — для text. Это высота ПРОПИСНОЙ (cap height): восходящие " +
|
||||
"и выносные элементы выходят за неё. Ширина строки линейна по height")]
|
||||
public double? Height { get; set; }
|
||||
|
||||
[JsonPropertyName("widthFactor")]
|
||||
[Description("Коэффициент сужения шрифта — для text: 1 обычный, <1 уже, >1 шире. По умолчанию 1")]
|
||||
[Description("Коэффициент ширины шрифта — для text: 1 обычный, <1 уже, >1 шире. Меняет пропорцию " +
|
||||
"начертания крупными шагами (1.0→1.25 даёт +29 % ширины), но КОМПАС квантует его " +
|
||||
"ступенями: соседние значения дают одинаковую ширину, а переход через ступень — " +
|
||||
"скачок ~2 %. Точную ширину набирайте height. По умолчанию 1")]
|
||||
public double? WidthFactor { get; set; }
|
||||
|
||||
[JsonPropertyName("bold")]
|
||||
@@ -77,6 +81,16 @@ public sealed class SketchEntityInput
|
||||
[Description("Наклонное начертание — для text. По умолчанию false")]
|
||||
public bool? Italic { get; set; }
|
||||
|
||||
[JsonPropertyName("align")]
|
||||
[Description("Чем считать точку вставки по горизонтали — для text: left (по умолчанию) | center | right. " +
|
||||
"center избавляет от цикла «вставил → замерил → переставил»")]
|
||||
public string? Align { get; set; }
|
||||
|
||||
[JsonPropertyName("vAlign")]
|
||||
[Description("Чем считать точку вставки по вертикали — для text: baseline (по умолчанию, выносные уходят " +
|
||||
"ниже) | bottom | middle | top — по фактическому габариту глифов")]
|
||||
public string? VAlign { get; set; }
|
||||
|
||||
[JsonPropertyName("vertexCount")]
|
||||
[Description("Число вершин (>=3) — для polygon")]
|
||||
public int? VertexCount { get; set; }
|
||||
@@ -197,6 +211,8 @@ public static class SketchEntityInputs
|
||||
WidthFactor = e.WidthFactor ?? 1.0,
|
||||
Bold = e.Bold ?? false,
|
||||
Italic = e.Italic ?? false,
|
||||
Align = TextAligns.Parse(e.Align),
|
||||
VAlign = TextAligns.ParseVertical(e.VAlign),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace Kompas.Mcp.Core.Modeling;
|
||||
|
||||
/// <summary>Чем считать точку вставки надписи по горизонтали.</summary>
|
||||
public enum TextAlign
|
||||
{
|
||||
/// <summary>Левый край глифов (как кладёт сам КОМПАС).</summary>
|
||||
Left,
|
||||
/// <summary>Середина строки — точка задаёт центр.</summary>
|
||||
Center,
|
||||
/// <summary>Правый край глифов.</summary>
|
||||
Right,
|
||||
}
|
||||
|
||||
/// <summary>Чем считать точку вставки надписи по вертикали.</summary>
|
||||
public enum TextVAlign
|
||||
{
|
||||
/// <summary>Базовая линия шрифта (как кладёт сам КОМПАС): выносные элементы уходят ниже.</summary>
|
||||
Baseline,
|
||||
/// <summary>Нижняя граница глифов (с учётом выносных).</summary>
|
||||
Bottom,
|
||||
/// <summary>Середина глифов по высоте.</summary>
|
||||
Middle,
|
||||
/// <summary>Верхняя граница глифов.</summary>
|
||||
Top,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Разбор выравнивания надписи. КОМПАС умеет класть строку только вправо-вверх от точки вставки
|
||||
/// (аргумент <c>align</c> у <c>ksTextEx</c> строку не двигает), поэтому центрирование — это
|
||||
/// «вставить, замерить, переставить». Раньше эти три шага тратил агент, теперь их делает сервер.
|
||||
/// </summary>
|
||||
public static class TextAligns
|
||||
{
|
||||
public static TextAlign Parse(string? value, string parameterName = "align")
|
||||
=> (value ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"" or "left" => TextAlign.Left,
|
||||
"center" or "centre" or "middle" => TextAlign.Center,
|
||||
"right" => TextAlign.Right,
|
||||
_ => throw new ArgumentException(
|
||||
$"Неизвестное выравнивание «{value}». Допустимо: left | center | right.", parameterName),
|
||||
};
|
||||
|
||||
public static TextVAlign ParseVertical(string? value, string parameterName = "vAlign")
|
||||
=> (value ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"" or "baseline" => TextVAlign.Baseline,
|
||||
"bottom" => TextVAlign.Bottom,
|
||||
"middle" or "center" or "centre" => TextVAlign.Middle,
|
||||
"top" => TextVAlign.Top,
|
||||
_ => throw new ArgumentException(
|
||||
$"Неизвестное вертикальное выравнивание «{value}». Допустимо: baseline | bottom | middle | top.",
|
||||
parameterName),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Насколько сдвинуть надпись, чтобы точка вставки стала выбранным якорем.
|
||||
/// <paramref name="box"/> — фактический габарит глифов после первой вставки.
|
||||
/// </summary>
|
||||
public static (double Dx, double Dy) Offset(
|
||||
(double MinX, double MinY, double MaxX, double MaxY) box,
|
||||
(double X, double Y) anchor,
|
||||
TextAlign align, TextVAlign vAlign)
|
||||
{
|
||||
var dx = align switch
|
||||
{
|
||||
TextAlign.Center => anchor.X - (box.MinX + box.MaxX) / 2,
|
||||
TextAlign.Right => anchor.X - box.MaxX,
|
||||
_ => 0,
|
||||
};
|
||||
var dy = vAlign switch
|
||||
{
|
||||
TextVAlign.Bottom => anchor.Y - box.MinY,
|
||||
TextVAlign.Middle => anchor.Y - (box.MinY + box.MaxY) / 2,
|
||||
TextVAlign.Top => anchor.Y - box.MaxY,
|
||||
_ => 0,
|
||||
};
|
||||
return (dx, dy);
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,55 @@ public sealed record EdgeInfo
|
||||
|
||||
/// <summary>Длина ребра, мм.</summary>
|
||||
public required double Length { get; init; }
|
||||
|
||||
/// <summary>Концевая вершина (начало), мировые координаты, мм. У замкнутых кривых совпадает с <see cref="End"/>.</summary>
|
||||
public (double X, double Y, double Z)? Start { get; init; }
|
||||
|
||||
/// <summary>Концевая вершина (конец), мировые координаты, мм.</summary>
|
||||
public (double X, double Y, double Z)? End { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отбор рёбер для пакетной операции. Дело не в удобстве: на теле с надписью рёбер сотни, полный
|
||||
/// список неподъёмен по объёму ответа, а без него нечем адресовать «весь нижний контур» —
|
||||
/// фаска против «слоновьей ноги» упиралась именно в это.
|
||||
/// </summary>
|
||||
public sealed record EdgeFilter
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public double? MinX { get; init; }
|
||||
public double? MaxX { get; init; }
|
||||
public double? MinY { get; init; }
|
||||
public double? MaxY { get; init; }
|
||||
public double? MinZ { get; init; }
|
||||
public double? MaxZ { get; init; }
|
||||
|
||||
/// <summary>Задан ли хоть один критерий (иначе перечисляем всё).</summary>
|
||||
public bool IsEmpty => Type is null && MinX is null && MaxX is null
|
||||
&& MinY is null && MaxY is null && MinZ is null && MaxZ is null;
|
||||
|
||||
/// <summary>
|
||||
/// Проходит ли ребро отбор. Координатное окно проверяется по ОБОИМ концам: у прямой и дуги это
|
||||
/// точный признак «ребро лежит в окне», у замкнутой кривой концы совпадают — тогда признак
|
||||
/// говорит лишь о том, что кривая проходит через окно.
|
||||
/// </summary>
|
||||
public bool Matches(EdgeInfo edge)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edge);
|
||||
|
||||
if (Type is { } type && !string.Equals(edge.Type, type.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (IsEmptyBox) return true;
|
||||
if (edge.Start is not { } s || edge.End is not { } e) return false; // без координат окно не проверить
|
||||
|
||||
return Inside(s) && Inside(e);
|
||||
}
|
||||
|
||||
private bool IsEmptyBox => MinX is null && MaxX is null && MinY is null
|
||||
&& MaxY is null && MinZ is null && MaxZ is null;
|
||||
|
||||
private bool Inside((double X, double Y, double Z) p)
|
||||
=> (MinX is null || p.X >= MinX) && (MaxX is null || p.X <= MaxX)
|
||||
&& (MinY is null || p.Y >= MinY) && (MaxY is null || p.Y <= MaxY)
|
||||
&& (MinZ is null || p.Z >= MinZ) && (MaxZ is null || p.Z <= MaxZ);
|
||||
}
|
||||
|
||||
@@ -135,8 +135,12 @@ public sealed class QueryService
|
||||
finally { ComHelper.Release(part); }
|
||||
}, ct);
|
||||
|
||||
/// <summary>Перечислить рёбра активной детали: индекс, тип кривой и длину (мм).</summary>
|
||||
public Task<IReadOnlyList<EdgeInfo>> ListEdgesAsync(CancellationToken ct = default)
|
||||
/// <summary>
|
||||
/// Перечислить рёбра активной детали: индекс, тип кривой, длину (мм) и концевые вершины.
|
||||
/// <paramref name="filter"/> отбирает подмножество — на теле с надписью рёбер сотни, и без
|
||||
/// отбора «весь нижний контур» адресовать нечем.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<EdgeInfo>> ListEdgesAsync(EdgeFilter? filter = null, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync<IReadOnlyList<EdgeInfo>>(() =>
|
||||
{
|
||||
var part = GetTopPart();
|
||||
@@ -151,18 +155,33 @@ public sealed class QueryService
|
||||
{
|
||||
if (edges.GetByIndex(i) is not ksEntity entity) continue;
|
||||
if (entity.GetDefinition() is not ksEdgeDefinition def) continue;
|
||||
list.Add(new EdgeInfo
|
||||
var info = new EdgeInfo
|
||||
{
|
||||
Index = i,
|
||||
Type = GeomClassifiers.EdgeType(def),
|
||||
Length = def.GetLength(MixMm),
|
||||
});
|
||||
Start = VertexPoint(def, start: true),
|
||||
End = VertexPoint(def, start: false),
|
||||
};
|
||||
if (filter is null || filter.Matches(info)) list.Add(info);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
finally { ComHelper.Release(part); }
|
||||
}, ct);
|
||||
|
||||
/// <summary>Координаты концевой вершины ребра или <c>null</c> (у части кривых вершин нет).</summary>
|
||||
private static (double X, double Y, double Z)? VertexPoint(ksEdgeDefinition def, bool start)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (def.GetVertex(start) is ksVertexDefinition v && v.GetPoint(out double x, out double y, out double z))
|
||||
return (x, y, z);
|
||||
}
|
||||
catch (System.Runtime.InteropServices.COMException) { /* нет вершины — не повод рушить перечисление */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Габаритный параллелепипед активной детали (по телам), мм.</summary>
|
||||
public Task<BoundingBox> GetBoundingBoxAsync(CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
|
||||
@@ -357,6 +357,19 @@ public sealed class FeatureTools(
|
||||
$"Уклон {angle}° создан на {count} гранях (нейтральная плоскость {neutralPlane}), id={id}.");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "feature_delete")]
|
||||
[Description("Удалить операцию или эскиз по id (из ответа sketch_create/extrude/…) — так убирают " +
|
||||
"неудачную пробу, не пересобирая документ с нуля. КОМПАС сносит зависимые объекты " +
|
||||
"КАСКАДОМ: удаляя эскиз, вы удаляете и построенные по нему операции. Удалять можно " +
|
||||
"только созданное в текущей сессии — после смены документа id недействительны.")]
|
||||
public async Task<string> FeatureDelete(
|
||||
[Description("Id эскиза или операции")] int id)
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
var deleted = await modeler.DeleteAsync(id);
|
||||
return await autoValidation.AnnotateAsync($"{deleted} удалена из дерева.");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "rebuild")]
|
||||
[Description("Перестроить активный документ. Нужен перед чтением МЦХ после правок переменных/геометрии.")]
|
||||
public async Task<string> Rebuild()
|
||||
|
||||
@@ -53,11 +53,22 @@ public sealed class QueryTools(KompasSession session, QueryService query, ModelI
|
||||
|
||||
[McpServerTool(Name = "list_edges")]
|
||||
[Description("Рёбра активной детали. Без index — список: индекс, тип кривой " +
|
||||
"(line/circle/arc/ellipse/nurbs) и длина (мм). С index — подробности этого ребра: длина, " +
|
||||
"две смежные грани и координаты концевых вершин. Индексы передавайте в fillet_edge / " +
|
||||
"chamfer_edge — это надёжнее выбора по точке.")]
|
||||
"(line/circle/arc/ellipse/nurbs), длина (мм) и концы. С index — подробности одного ребра " +
|
||||
"(длина, смежные грани, концевые вершины). Индексы передавайте в fillet_edge / " +
|
||||
"chamfer_edge — это надёжнее выбора по точке.\n" +
|
||||
"ОТБОР: type и координатное окно minX/maxX/minY/maxY/minZ/maxZ оставляют только рёбра, " +
|
||||
"ОБА конца которых лежат в окне. Так адресуют то, что иначе не выбрать: весь нижний " +
|
||||
"контур под фаску — list_edges(minZ=-0.01, maxZ=0.01), прямые участки — type=\"line\". " +
|
||||
"На теле с надписью рёбер сотни, и полный список бесполезен по объёму.")]
|
||||
public async Task<string> ListEdges(
|
||||
[Description("Индекс ребра для подробностей; пусто — список всех рёбер")] int? index = null)
|
||||
[Description("Индекс ребра для подробностей; пусто — список рёбер")] int? index = null,
|
||||
[Description("Тип кривой: line | circle | arc | ellipse | ellipse_arc | nurbs")] string? type = null,
|
||||
[Description("Нижняя граница окна по X, мм")] double? minX = null,
|
||||
[Description("Верхняя граница окна по X, мм")] double? maxX = null,
|
||||
[Description("Нижняя граница окна по Y, мм")] double? minY = null,
|
||||
[Description("Верхняя граница окна по Y, мм")] double? maxY = null,
|
||||
[Description("Нижняя граница окна по Z, мм")] double? minZ = null,
|
||||
[Description("Верхняя граница окна по Z, мм")] double? maxZ = null)
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
|
||||
@@ -72,10 +83,30 @@ public sealed class QueryTools(KompasSession session, QueryService query, ModelI
|
||||
return detail;
|
||||
}
|
||||
|
||||
var edges = await query.ListEdgesAsync();
|
||||
if (edges.Count == 0) return "Рёбер нет (модель пуста?).";
|
||||
var lines = edges.Select(e => $" [{e.Index}] {e.Type}, длина {e.Length:F2} мм");
|
||||
return $"Рёбер: {edges.Count}\n" + string.Join("\n", lines);
|
||||
var filter = new EdgeFilter
|
||||
{
|
||||
Type = type, MinX = minX, MaxX = maxX,
|
||||
MinY = minY, MaxY = maxY, MinZ = minZ, MaxZ = maxZ,
|
||||
};
|
||||
var edges = await query.ListEdgesAsync(filter);
|
||||
if (edges.Count == 0)
|
||||
return filter.IsEmpty
|
||||
? "Рёбер нет (модель пуста?)."
|
||||
: "Под отбор не попало ни одно ребро — ослабьте окно или уберите type.";
|
||||
|
||||
// Координаты печатаем, когда список обозрим: на сотнях рёбер они топят ответ, а пользы
|
||||
// без отбора всё равно нет — сначала сузьте окно.
|
||||
var withPoints = edges.Count <= 60;
|
||||
var lines = edges.Select(e =>
|
||||
{
|
||||
var head = Inv($" [{e.Index}] {e.Type}, длина {e.Length:F2} мм");
|
||||
if (!withPoints || e.Start is not { } s || e.End is not { } en) return head;
|
||||
return head + Inv($", концы ({s.X:F2}, {s.Y:F2}, {s.Z:F2}) → ({en.X:F2}, {en.Y:F2}, {en.Z:F2})");
|
||||
});
|
||||
|
||||
var head = filter.IsEmpty ? $"Рёбер: {edges.Count}" : $"Рёбер под отбор: {edges.Count}";
|
||||
var hint = withPoints ? "" : "\n(координаты скрыты — сузьте отбор, чтобы их увидеть)";
|
||||
return head + "\n" + string.Join("\n", lines) + hint;
|
||||
}
|
||||
|
||||
private static string Inv(FormattableString fs) => FormattableString.Invariant(fs);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Kompas.Mcp.Core.Query;
|
||||
|
||||
namespace Kompas.Mcp.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Отбор рёбер. На теле с надписью их сотни: без окна нельзя адресовать «весь нижний контур»,
|
||||
/// а полный список бесполезен по объёму ответа.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EdgeFilterTests
|
||||
{
|
||||
private static EdgeInfo Edge(int index, string type,
|
||||
(double, double, double)? start, (double, double, double)? end, double length = 10)
|
||||
=> new() { Index = index, Type = type, Length = length, Start = start, End = end };
|
||||
|
||||
[Fact]
|
||||
public void Empty_filter_takes_everything()
|
||||
{
|
||||
var filter = new EdgeFilter();
|
||||
|
||||
Assert.True(filter.IsEmpty);
|
||||
Assert.True(filter.Matches(Edge(0, "line", (0, 0, 0), (10, 0, 0))));
|
||||
Assert.True(filter.Matches(Edge(1, "nurbs", null, null)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Window_keeps_only_edges_with_both_ends_inside()
|
||||
{
|
||||
var bottom = new EdgeFilter { MinZ = -0.01, MaxZ = 0.01 };
|
||||
|
||||
Assert.True(bottom.Matches(Edge(0, "line", (0, 0, 0), (10, 0, 0)))); // весь низ
|
||||
Assert.False(bottom.Matches(Edge(1, "line", (0, 0, 0), (0, 0, 3)))); // вертикальное
|
||||
Assert.False(bottom.Matches(Edge(2, "line", (0, 0, 3), (10, 0, 3)))); // весь верх
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Edge_without_vertices_cannot_satisfy_a_window()
|
||||
{
|
||||
var filter = new EdgeFilter { MinZ = 0, MaxZ = 0 };
|
||||
Assert.False(filter.Matches(Edge(0, "nurbs", null, null)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Type_filter_is_case_insensitive_and_independent_of_the_window()
|
||||
{
|
||||
var lines = new EdgeFilter { Type = " LINE " };
|
||||
|
||||
Assert.False(lines.IsEmpty);
|
||||
Assert.True(lines.Matches(Edge(0, "line", (0, 0, 0), (10, 0, 0))));
|
||||
Assert.False(lines.Matches(Edge(1, "circle", (0, 0, 0), (0, 0, 0))));
|
||||
Assert.True(lines.Matches(Edge(2, "line", null, null))); // без окна координаты не нужны
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Window_and_type_are_combined()
|
||||
{
|
||||
var filter = new EdgeFilter { Type = "line", MinZ = -0.01, MaxZ = 0.01 };
|
||||
|
||||
Assert.True(filter.Matches(Edge(0, "line", (0, 0, 0), (10, 0, 0))));
|
||||
Assert.False(filter.Matches(Edge(1, "arc", (0, 0, 0), (10, 0, 0))));
|
||||
Assert.False(filter.Matches(Edge(2, "line", (0, 0, 0), (10, 0, 3))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
using Kompas.Mcp.Core.Query;
|
||||
|
||||
namespace Kompas.Mcp.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Интеграция: удаление операции/эскиза. Без него любая неудачная проба (не тот кегль надписи,
|
||||
/// не та глубина) оставалась в дереве навсегда, и единственным выходом было закрыть документ
|
||||
/// без сохранения и построить всё заново.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Collection(KompasCollection.Name)]
|
||||
public sealed class FeatureDeleteTests : IntegrationTestBase
|
||||
{
|
||||
private readonly DocumentService _docs;
|
||||
private readonly PartModeler _modeler;
|
||||
private readonly ModelInspectionService _inspection;
|
||||
private readonly QueryService _query;
|
||||
|
||||
public FeatureDeleteTests(KompasFixture fx) : base(fx)
|
||||
{
|
||||
_docs = new DocumentService(fx.Session, fx.Dispatcher);
|
||||
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
|
||||
_inspection = new ModelInspectionService(fx.Session, fx.Dispatcher);
|
||||
_query = new QueryService(fx.Session, fx.Dispatcher);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> TreeNamesAsync()
|
||||
=> (await _inspection.DescribeModelAsync(InspectionText.ModelSections.Tree))
|
||||
.Features.Select(f => f.Name).ToList();
|
||||
|
||||
[Fact]
|
||||
public async Task Deleted_extrusion_leaves_the_tree_and_gives_the_material_back()
|
||||
{
|
||||
await _docs.CreateAsync(KompasDocumentType.Part);
|
||||
try
|
||||
{
|
||||
var plate = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Контур плиты");
|
||||
await _modeler.AddRectangleAsync(plate, 0, 0, 20, 10);
|
||||
await _modeler.CloseSketchAsync(plate);
|
||||
await _modeler.ExtrudeAsync(plate, depth: 3, name: "Плита");
|
||||
|
||||
var probe = await _modeler.OpenSketchOnOffsetPlaneAsync(BasePlane.XOY, offset: 3, name: "Проба кегля");
|
||||
await _modeler.AddRectangleAsync(probe, 2, 2, 8, 8);
|
||||
await _modeler.CloseSketchAsync(probe);
|
||||
var probeId = await _modeler.ExtrudeAsync(probe, depth: 5, name: "Неудачная проба");
|
||||
await _modeler.RebuildAsync();
|
||||
var withProbe = (await _query.GetPartInfoAsync()).Volume;
|
||||
|
||||
var deleted = await _modeler.DeleteAsync(probeId);
|
||||
await _modeler.RebuildAsync();
|
||||
|
||||
Assert.Contains("Неудачная проба", deleted, StringComparison.Ordinal);
|
||||
var names = await TreeNamesAsync();
|
||||
Assert.DoesNotContain("Неудачная проба", names);
|
||||
Assert.Contains("Плита", names);
|
||||
|
||||
// 20×10×3 = 600: проба ушла вместе со своим материалом (6×6×5 = 180).
|
||||
var after = (await _query.GetPartInfoAsync()).Volume;
|
||||
Assert.InRange(after, 594, 606);
|
||||
Assert.True(withProbe > after, "проба не добавляла материала — тест ничего не проверяет");
|
||||
}
|
||||
finally { await _docs.CloseAsync(save: false); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deleting_a_sketch_takes_its_operation_with_it()
|
||||
{
|
||||
await _docs.CreateAsync(KompasDocumentType.Part);
|
||||
try
|
||||
{
|
||||
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Контур пробы");
|
||||
await _modeler.AddRectangleAsync(s, 0, 0, 20, 10);
|
||||
await _modeler.CloseSketchAsync(s);
|
||||
await _modeler.ExtrudeAsync(s, depth: 3, name: "Тело пробы");
|
||||
await _modeler.RebuildAsync();
|
||||
|
||||
await _modeler.DeleteAsync(s); // КОМПАС удаляет зависимые каскадом
|
||||
await _modeler.RebuildAsync();
|
||||
|
||||
var names = await TreeNamesAsync();
|
||||
Assert.DoesNotContain("Контур пробы", names);
|
||||
Assert.DoesNotContain("Тело пробы", names);
|
||||
}
|
||||
finally { await _docs.CloseAsync(save: false); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_id_says_what_can_be_deleted()
|
||||
{
|
||||
await _docs.CreateAsync(KompasDocumentType.Part);
|
||||
try
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<KeyNotFoundException>(() => _modeler.DeleteAsync(4242));
|
||||
Assert.Contains("4242", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
finally { await _docs.CloseAsync(save: false); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Open_sketch_is_closed_before_deletion()
|
||||
{
|
||||
await _docs.CreateAsync(KompasDocumentType.Part);
|
||||
try
|
||||
{
|
||||
// Эскиз остался в режиме редактирования — удаление обязано с этим справиться само.
|
||||
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Открытая проба");
|
||||
await _modeler.AddRectangleAsync(s, 0, 0, 10, 10);
|
||||
|
||||
await _modeler.DeleteAsync(s);
|
||||
await _modeler.RebuildAsync();
|
||||
|
||||
Assert.DoesNotContain("Открытая проба", await TreeNamesAsync());
|
||||
}
|
||||
finally { await _docs.CloseAsync(save: false); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
using Kompas.Mcp.Core.Query;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Kompas.Mcp.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Интеграция: выравнивание надписи. КОМПАС кладёт строку только вправо-вверх от точки вставки
|
||||
/// (<c>align</c> у <c>ksTextEx</c> её не двигает), поэтому центрирование делает сервер: строит,
|
||||
/// замеряет, удаляет и кладёт заново со сдвигом.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Collection(KompasCollection.Name)]
|
||||
public sealed class TextAlignmentTests : IntegrationTestBase
|
||||
{
|
||||
private readonly DocumentService _docs;
|
||||
private readonly PartModeler _modeler;
|
||||
private readonly QueryService _query;
|
||||
|
||||
public TextAlignmentTests(KompasFixture fx) : base(fx)
|
||||
{
|
||||
_docs = new DocumentService(fx.Session, fx.Dispatcher);
|
||||
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
|
||||
_query = new QueryService(fx.Session, fx.Dispatcher);
|
||||
}
|
||||
|
||||
private static SketchEntity Text(string text, double x, double y, TextAlign align, TextVAlign vAlign) => new()
|
||||
{
|
||||
Kind = SketchEntityKind.Text,
|
||||
Points = [(x, y)],
|
||||
Text = text,
|
||||
Height = 10,
|
||||
FontName = "Arial",
|
||||
Align = align,
|
||||
VAlign = vAlign,
|
||||
};
|
||||
|
||||
/// <summary>Числа из заметки: minX, maxX, minY, maxY.</summary>
|
||||
private static (double MinX, double MaxX, double MinY, double MaxY) Box(string note)
|
||||
{
|
||||
var n = Regex.Matches(note, @"-?\d+\.\d{2}")
|
||||
.Select(m => double.Parse(m.Value, CultureInfo.InvariantCulture)).ToList();
|
||||
Assert.True(n.Count >= 4, $"в заметке нет габарита: {note}");
|
||||
return (n[0], n[1], n[2], n[3]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Centered_text_puts_its_middle_on_the_insertion_point()
|
||||
{
|
||||
await _docs.CreateAsync(KompasDocumentType.Part);
|
||||
try
|
||||
{
|
||||
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Надпись по центру");
|
||||
var notes = await _modeler.AddEntitiesAsync(
|
||||
s, [Text("HHH", 50, 0, TextAlign.Center, TextVAlign.Baseline)], autoClose: true);
|
||||
|
||||
var box = Box(notes[0]);
|
||||
Assert.InRange((box.MinX + box.MaxX) / 2, 49.95, 50.05);
|
||||
|
||||
// Надпись осталась ОДНА: первая вставка удалена, а не оставлена рядом.
|
||||
await _modeler.ExtrudeAsync(s, depth: 2, name: "Рельеф");
|
||||
await _modeler.RebuildAsync();
|
||||
var modelBox = await _query.GetBoundingBoxAsync();
|
||||
var what = $"заметка: {notes[0]}; модель X {modelBox.MinX:F2}…{modelBox.MaxX:F2}";
|
||||
Assert.True(Math.Abs(modelBox.MinX - box.MinX) < 0.05, what);
|
||||
Assert.True(Math.Abs(modelBox.MaxX - box.MaxX) < 0.05, what);
|
||||
}
|
||||
finally { await _docs.CloseAsync(save: false); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Right_and_middle_anchor_the_box_edges()
|
||||
{
|
||||
await _docs.CreateAsync(KompasDocumentType.Part);
|
||||
try
|
||||
{
|
||||
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Надпись по правому краю");
|
||||
var notes = await _modeler.AddEntitiesAsync(
|
||||
s, [Text("Hy", 90, 20, TextAlign.Right, TextVAlign.Middle)], autoClose: true);
|
||||
|
||||
var box = Box(notes[0]);
|
||||
Assert.InRange(box.MaxX, 89.95, 90.05); // правый край — на якоре
|
||||
Assert.InRange((box.MinY + box.MaxY) / 2, 19.95, 20.05); // середина глифов — на якоре
|
||||
}
|
||||
finally { await _docs.CloseAsync(save: false); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_alignment_keeps_the_kompas_behaviour()
|
||||
{
|
||||
await _docs.CreateAsync(KompasDocumentType.Part);
|
||||
try
|
||||
{
|
||||
var s = await _modeler.OpenSketchAsync(BasePlane.XOY, name: "Надпись от точки");
|
||||
var notes = await _modeler.AddEntitiesAsync(
|
||||
s, [Text("HH", 5, 0, TextAlign.Left, TextVAlign.Baseline)], autoClose: true);
|
||||
|
||||
var box = Box(notes[0]);
|
||||
Assert.True(box.MinX >= 5, "по умолчанию строка кладётся вправо от точки вставки");
|
||||
Assert.InRange(box.MinY, -0.5, 0.5); // базовая линия — на y точки вставки
|
||||
}
|
||||
finally { await _docs.CloseAsync(save: false); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
|
||||
namespace Kompas.Mcp.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Выравнивание надписи. КОМПАС кладёт строку только вправо-вверх от точки вставки, поэтому
|
||||
/// «по центру» — это пересчёт по фактическому габариту глифов; раньше его делал агент отдельным
|
||||
/// кругом «вставил → замерил → пересобрал».
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TextAlignsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, TextAlign.Left)]
|
||||
[InlineData("", TextAlign.Left)]
|
||||
[InlineData("left", TextAlign.Left)]
|
||||
[InlineData("CENTER", TextAlign.Center)]
|
||||
[InlineData(" centre ", TextAlign.Center)]
|
||||
[InlineData("right", TextAlign.Right)]
|
||||
public void Horizontal_alignment_is_parsed(string? value, TextAlign expected)
|
||||
=> Assert.Equal(expected, TextAligns.Parse(value));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, TextVAlign.Baseline)]
|
||||
[InlineData("bottom", TextVAlign.Bottom)]
|
||||
[InlineData("middle", TextVAlign.Middle)]
|
||||
[InlineData("top", TextVAlign.Top)]
|
||||
public void Vertical_alignment_is_parsed(string? value, TextVAlign expected)
|
||||
=> Assert.Equal(expected, TextAligns.ParseVertical(value));
|
||||
|
||||
[Fact]
|
||||
public void Unknown_alignment_lists_the_allowed_values()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => TextAligns.Parse("justify"));
|
||||
Assert.Contains("left", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("center", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Centering_moves_the_box_middle_onto_the_anchor()
|
||||
{
|
||||
// Глифы легли на 10…30 (середина 20), якорь — 0: сдвиг −20.
|
||||
var (dx, dy) = TextAligns.Offset((10, 0, 30, 12), (0, 0), TextAlign.Center, TextVAlign.Baseline);
|
||||
|
||||
Assert.Equal(-20, dx, 9);
|
||||
Assert.Equal(0, dy, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Right_alignment_puts_the_box_end_onto_the_anchor()
|
||||
{
|
||||
var (dx, _) = TextAligns.Offset((10, 0, 30, 12), (90, 0), TextAlign.Right, TextVAlign.Baseline);
|
||||
Assert.Equal(60, dx, 9); // 90 − 30
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vertical_middle_uses_the_glyph_box_not_the_baseline()
|
||||
{
|
||||
// Выносные ушли ниже базовой линии: габарит −3…12, середина 4.5.
|
||||
var (_, dy) = TextAligns.Offset((0, -3, 20, 12), (0, 0), TextAlign.Left, TextVAlign.Middle);
|
||||
Assert.Equal(-4.5, dy, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Left_baseline_is_a_no_op()
|
||||
{
|
||||
var (dx, dy) = TextAligns.Offset((10, -3, 30, 12), (5, 7), TextAlign.Left, TextVAlign.Baseline);
|
||||
Assert.Equal(0, dx, 9);
|
||||
Assert.Equal(0, dy, 9);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user