diff --git a/.claude/skills/kompas-3d/SKILL.md b/.claude/skills/kompas-3d/SKILL.md new file mode 100644 index 0000000..cdd2af5 --- /dev/null +++ b/.claude/skills/kompas-3d/SKILL.md @@ -0,0 +1,110 @@ +--- +name: kompas-3d +description: > + Методика работы с КОМПАС-3D через MCP-сервер этого проекта: построение и + модификация деталей, импорт/экспорт STEP, работа со сборками, осмотр геометрии + снимками и запросами. Используй ВСЕГДА, когда задача — что-то СДЕЛАТЬ в КОМПАС + через MCP-инструменты (создать/править деталь, импортировать STEP, разобрать + сборку, померить, отрендерить, экспортировать). Здесь — playbook'и и эвристики, + выработанные в usecases/. Триггеры: «построй деталь в КОМПАС», «импортируй STEP», + «что в этой сборке», «нарасти/измени деталь», «экспортируй STEP», «сделай снимок модели». + НЕ для поиска по справке SDK — это навык kompas-sdk-research. +--- + +# kompas-3d — методика управления КОМПАС-3D через MCP + +## Два слоя (важно не смешивать) + +- **MCP-сервер** (`src/Kompas.Mcp.Host`) — *инструмент*. Он **транслирует возможности COM API/SDK + КОМПАС** в MCP-инструменты. Инструменты делаются **общими** (по возможностям SDK), а не под + конкретную задачу. Расширяя MCP, добавляй универсальную операцию (например `import_step`, + `list_components`, `export_step`, «переместить грань», «рассечь тело»), а не «сделать вот эту деталь». +- **Этот навык** — *методика*. Как из общих инструментов собрать решение: порядок шагов, + выбор геометрии, проверка результата, обход подводных камней. Подходы вырабатываются в + **`usecases/`** и поднимаются сюда. + +Итог: **MCP = чем делать, навык = как делать.** Новый приём сначала обкатывается кейсом, потом +обобщается: инструмент → в MCP, метод → в этот навык. + +## Когда применять + +Любая задача «сделать что-то В КОМПАС» через MCP: создать/править деталь, эскизы и операции, +импорт/экспорт обменных форматов, разбор сборки, измерения, снимки. Для поиска сигнатур/констант +в справке — отдельный навык `kompas-sdk-research`. Для правки документации — `docs-delegate`. + +## Инструменты MCP + +**Реализованы (v2+, 35 инструментов):** `kompas_connect`, `kompas_set_visible`, `kompas_status`; +`document_create|open|save|save_as|close|active`; `sketch_create`, `sketch_create_on_face[_index]`, +`sketch_add_line|circle|rectangle|axis`, `sketch_close`; `extrude_boss|cut`, `revolve_boss|cut`, +`fillet_edge[_index]`, `chamfer_edge[_index]`, `rebuild`; **`move_face`** (прямое редактирование: сдвинуть грань на N мм); +`get_part_info`, `get_bounding_box`, `list_faces`, `list_edges`, `list_components`; +`import_step`, `export_step`; `model_snapshot`. + +**Планируются (трансляция SDK, по мере кейсов):** рассечение/перемещение тела как инструменты +(`SplitSolids`/`BodyRepositions` — механика есть, продуктизация не закончена), `list_bodies`, +массивы/паттерны, переменные/свойства. + +> Если нужного инструмента ещё нет — не хардкодь обход под кейс. Заведи кейс в `usecases/`, +> докажи механику (тест или минимальный прогон в usecases/), затем продуктизируй как +> **общий** инструмент MCP (с тестами, TDD) и опиши приём здесь. + +## Базовый цикл (эскиз → операция → осмотр) + +Опорный сценарий построения, проверен end-to-end (см. `usecases/0001`): + +1. `kompas_connect` (+ `kompas_set_visible true`, чтобы снимки были осмысленны). +2. `document_create part`. +3. `sketch_create XOY` → `sketch_add_*` → `sketch_close`. +4. `extrude_boss`/`revolve_boss`/… +5. **Осмотр:** `model_snapshot` (увидеть) + `get_part_info`/`get_bounding_box` (сверить числами). +6. Итерация «на грани»: `model_snapshot` → `list_faces` → `sketch_create_on_face_index` → операция. + +## Работа с импортом / сборками (выработано в usecases/0002) + +Конвейер «импорт → разбор → извлечение детали → осмотр → модификация → экспорт»: + +1. **Импорт STEP** в новый документ (под капотом: встроенный конвертер по коду формата + `ksConverterFromSTEP`, затем `ConvertFromAdditionFormat`). Тип документа выбирается по + содержимому: сборка → `.a3d`, одиночное тело → деталь. +2. **Разбор сборки**: перечислить компоненты (имя, обозначение, габарит, файл) — обход `TopPart → Parts`. +3. **Извлечение отдельной детали**: при импорте включать «создавать файлы компонентов», тогда + компоненты пишутся как отдельные `.m3d` рядом; открывать деталь самостоятельным документом + (`OpenSourceDocument`). Гашение видимости компонента на снимок **не влияет** — изоляция так не делается. +4. **Осмотр детали**: габарит по осям (какая ось — «высота»), `list_faces`/`list_edges`, стандартные + проекции (`#Спереди/#Сверху/#Слева/#Справа/#Изометрия`) + `model_snapshot` для каждой. +5. **Модификация**: на «тупой» импортированной B-rep работает `move_face` — сдвинуть грань на +N мм + (наружу, добавить материал) или −N мм (внутрь). Рассечение/раздвижка тела — механика есть, + отдельных инструментов пока нет (см. ниже). +6. **Экспорт STEP**: `ConvertToAdditionFormat` (есть AP203/AP214/AP242). + +Точные COM-приёмы и сигнатуры — в авто-памяти `kompas-step-and-assembly-api` и в `usecases/0002/case.md`. + +## Эвристики и подводные камни + +- **Решай по снимку.** После каждой формообразующей операции делай `model_snapshot` и проверяй + визуально; числами — `get_part_info`/`get_bounding_box`. +- **Грань/ребро по индексу надёжнее, чем по точке.** Сначала `list_faces`/`list_edges`, затем + `*_on_face_index` / `*_edge_index`. По точке (`SelectByPoint`) — только когда индекс не подходит. +- **Направление операции.** `extrude_boss(..., forward)` может уйти «не туда» в зависимости от + нормали выбранной грани — проверь снимком, при необходимости инвертируй `forward`. +- **МЦХ.** Объём/масса меняющейся геометрии бери через `get_part_info` (под капотом API5 + `CalcMassInertiaProperties`); перед чтением после правок — `rebuild`. +- **id эскизов/операций** недействительны после смены активного документа (create/open/close). +- **Единицы — мм** (геометрия) и кг (масса). Локальные координаты эскиза ≠ мировые координаты модели. + +## Открытые вопросы / границы + +- **Перемещение грани (`move_face`) — реализовано.** Работает на импортированной B-rep через + `IPart7.FindObjectsByPoint` + `FaceMover`. Проверено: top_spacer 39.45→41.45 мм. +- **Рассечение/перемещение тела** (`SplitSolids`/`BodyRepositions`) — механика задокументирована, + отдельными MCP-инструментами не оформлена. Возможная следующая опция. +- Сборки/паттерны/переменные в MCP пока не реализованы. + +## Связанное + +- Инструменты и их реализация: `src/Kompas.Mcp.Host/Tools/*`, `src/Kompas.Mcp.Core/*`. +- Кейсы (полигон подходов): `usecases/` (см. `usecases/README.md`). +- Поиск по справке SDK: навык **kompas-sdk-research**. +- Правка документации проекта: навык **docs-delegate**. +- Память: `kompas-step-and-assembly-api` (проверенные COM-приёмы импорта/экспорта/обхода). diff --git a/CLAUDE.md b/CLAUDE.md index eab37b4..5409199 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,14 +31,14 @@ The index lives at `docs/kompas_sdk_index.tsv` (`filenametitleexcerpt` ## Current state -**v1 + v2 implemented and working** (full sketch→feature→inspect loop validated end-to-end). Stack: +**v1 + v2 + STEP/assembly + direct B-rep edit + structural model inspection implemented and working** (full sketch→feature→inspect→STEP round-trip + move_face + describe_model validated end-to-end). Stack: **.NET 8 (`net8.0-windows`, x64), C#**, MCP via the official `ModelContextProtocol` SDK over **stdio**. -**31 MCP tools, 19 tests green.** v2 added: sketch-on-face (by point and by index), revolve (boss/cut + -sketch axis), fillet/chamfer (edge-by-point and by index), and Query tools `get_part_info` (МЦХ), -`get_bounding_box`, `list_faces`, `list_edges`. +**43 MCP tools, 60 tests green.** v2 added: sketch-on-face (by point and by index), revolve (boss/cut + sketch axis), fillet/chamfer (edge-by-point and by index), and Query tools `get_part_info` (МЦХ), `get_bounding_box`, `list_faces`, `list_edges`. After v2: `import_step`, `export_step` (`ConversionService`, `ConversionTools`), `list_components` (assembly traversal via `TopPart → IParts7`). `move_face` (`FaceEditService`, `EditTools`) — direct B-rep face editing. Latest: **structural model inspection** — `describe_model`, `list_features`, `list_bodies`, `list_variables`, `describe_face`, `describe_edge`, `measure` (`ModelInspectionService`, `InspectionTools`). `describe_model` is the preferred first call over `model_snapshot` (no token cost for image context; gives feature tree, bodies, variables, topology summary, МЦХ). See [`README.md`](README.md) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); design decisions/caveats in [`docs/OPEN_QUESTIONS.md`](docs/OPEN_QUESTIONS.md). +Principle: **MCP = translating SDK capabilities into general tools** (not task-specific). On top of MCP — skill **`.claude/skills/kompas-3d/`** with a methodology (playbooks, heuristics, validated in `usecases/`). Layout: `usecases/` (in .gitignore) is the proving ground; proven techniques are promoted to the skill. + Layout: `src/Kompas.Mcp.Core` (COM layer), `src/Kompas.Mcp.Host` (MCP stdio server + tools), `tests/Kompas.Mcp.Tests` (unit + integration), `libs/kompas-interop/*.dll` (vendored КОМПАС interop assemblies from SDK `Samples/Common`, referenced via `Directory.Build.props` → `KompasInteropDir`). @@ -56,9 +56,15 @@ Key implementation facts (don't relearn): - 3D is built via **API5 `ksPart`** (`NewEntity(o3d_sketch/o3d_bossExtrusion/o3d_bossRotated/o3d_fillet/o3d_chamfer/...)`, `ksBossExtrusionDefinition`/`ksCutExtrusionDefinition`/`ksBossRotatedDefinition`/`ksFilletDefinition`/`ksChamferDefinition`); cut-through holes use `dtBoth` + `etThroughAll`. API7 is used for app/documents. - **Face/edge selection**: `ksPart.EntityCollection(o3d_face|o3d_edge)` → `SelectByPoint(x,y,z)` (world mm) filters to objects through the point, then `GetByIndex(0)`; or by stable index from `list_faces`/`list_edges`. `ksFaceDefinition.IsPlanar/IsCylinder/...` + `GetArea(ST_MIX_MM)` classify faces. `ksEdgeDefinition.IsLineSeg/IsCircle/IsArc/...` + `GetLength()` classify/measure edges. Sketch axis = line with system style **3** (осевая). Fillet/chamfer by edge index via `fillet_edge_index`/`chamfer_edge_index` (more reliable than by-point). - **МЦХ / mass props**: use **API5 `ksPart.CalcMassInertiaProperties(ST_MIX_MM|ST_MIX_KG)`** → `ksMassInertiaParam` (`v`/`m`/`F`/`xc`/`yc`/`zc`), computed on demand. **Do NOT** use API7 `IMassInertiaParam7.Calculate()` — its `Actual` flag sticks after the first calc and won't refresh on API5 geometry changes (stale volume). Bounding box: `ksPart.GetGabarit(full:false, ...)`. -- **Snapshot**: `ksDocument3D.SaveAsToRasterFormat(file, ksRasterFormatParam)` renders to a temp file (the in-memory `resultArrayBytes` stays empty when a filename is given); read bytes back. Return to MCP via `ImageContentBlock.FromBytes(bytes, mime)` — **not** `Data = bytes` (Data holds base64 bytes). +- **Snapshot**: `ksDocument3D.SaveAsToRasterFormat(file, ksRasterFormatParam)` renders to a temp file (the in-memory `resultArrayBytes` stays empty when a filename is given); read bytes back. Return to MCP via `ImageContentBlock.FromBytes(bytes, mime)` — **not** `Data = bytes` (Data holds base64 bytes). Use `model_snapshot` only for visually-spatial questions; **prefer `describe_model` first**. - Logs go to **stderr** (stdout is the MCP channel). Integration tests reuse one КОМПАС via `KompasFixture`; artifacts land in gitignored `.scratch/`. -- Not yet done (next): patterns/arrays, `list_bodies`, variables/properties (`IVariable7`), 2D drawing, assemblies, a proper in-process MCP-client test. Known caveat: boss/cut direction on a selected face depends on face-normal orientation (`forward` may need flipping — agent picks via snapshot). See `docs/OPEN_QUESTIONS.md` → «Ревью v2». +- **Structural model inspection** (`ModelInspectionService`, `InspectionTools`): `describe_model` — one-call structural "passport" (bounding box + МЦХ + bodies + topology summary + feature tree + variables); preferred over snapshot. `list_features` — feature tree with params (depth/radius/legs). `list_bodies` — list solid/surface bodies with face count. `list_variables` — model variables (name/expression/value, external/info flags). `describe_face` — drill-down by index (type, area, normal, radius, edge count). `describe_edge` — drill-down by index (type, length, adjacent faces, vertices). `measure` — distance/angle between two objects (face|edge|vertex by index, unit `ST_MIX_MM`). +- **Feature tree API** (don't relearn): read via API5 `ksPart.GetFeature()` → `(ksFeature).SubFeatureCollection(true,false)` → `ksFeatureCollection`. Cast `(ksFeature)part` does NOT work (RCW QI returns null) — must use `GetFeature()`. `ksFeature.type` returns only coarse `o3d_entity` and does NOT distinguish operations; precise type and params come from `ksFeature.GetObject()` → `ksEntity.GetDefinition()` and matching definition type (`ksBossExtrusionDefinition.GetSideParam`, `ksFilletDefinition.radius`, `ksChamferDefinition.GetChamferParam`, …). Node name ("Элемент выдавливания:1", "Скругление:1") is localized by КОМПАС itself. +- **STEP import/export, `list_components`, `move_face`, and full inspection layer are done.** Not yet done: body split/reposition as MCP tools (SplitSolids/BodyRepositions — driveable but not yet implemented); patterns/arrays, 2D drawing, assembly building. Known caveat: boss/cut direction on a selected face depends on face-normal orientation (`forward` may need flipping — agent picks via snapshot or `describe_face` normal). See `docs/OPEN_QUESTIONS.md` → «Ревью v2». +- **"STEP import without history"** detected structurally: `bodyCount > 0 && no formative features && features.Count <= bodyCount+1` (only origin + body). STEP with edits (move_face: "Смещённая плоскость", "Разрезать", "Переместить грани", "Булева операция") — that already has history. +- **Topology/measure API**: `ksFaceDefinition.EdgeCollection` / `GetCylinderParam` / `GetSurface().GetNormal` + `normalOrientation`; `ksEdgeDefinition.GetAdjacentFace(bool)` / `GetVertex(bool)` → `ksVertexDefinition.GetPoint`; bodies via `ksPart.BodyCollection()` → `ksBody` (`IsSolid` / `FaceCollection`); variables via `ksPart.VariableCollection()` → `ksVariable`; measurements via `ksPart.GetMeasurer()` → `ksMeasurer` (`SetObject1/2`, `unit=ST_MIX_MM`, `Calc`, `distance`/`MinDistance`/`angle` in degrees / `IsAngleValid`). +- **STEP import COM pattern** (verified): `IApplication.get_Converter((object)(int)ksConverterFromSTEP=-3)` (pass format code, not DLL path) → `IConverter.ConverterParameters(cmd)` → set `IAdditionConvertParameters.Format=ksConverterFromSTEP` → `IKompasDocument3D1.ConvertFromAdditionFormat(path, prm)`. Export: `ConvertToAdditionFormat` with format codes for AP203/AP214/AP242. Param co-classes (AdditionConvertParameters etc.) are NOT CoCreatable — only via converter factories. Assembly traversal: `IKompasDocument3D.TopPart → IPart7.Parts (IParts7)`. Extract component: `prm.NeedCreateComponentsFiles=true` (writes .m3d files next to STEP) + `IPart7.OpenSourceDocument`. +- **Direct B-rep face editing (`move_face`)** (verified on top_spacer, 39.45→41.45 mm height): API7 face object retrieved via `IPart7.FindObjectsByPoint(x,y,z,true)` → cast to `KompasAPI7.IFace`; containers acquired at runtime via COM-QI: `(ISurfaceContainer)part`, `(IModelContainer)part`; `FaceMover` (SetFaces + Offset + Direction + Update) moves the face. distance>0 = outward (add material), <0 = inward. Works on both parametric and imported B-rep geometry. Full split-reposition workflow (SplitSolids/BodyRepositions) is driveable but not yet exposed as tools. ## КОМПАС-3D API architecture (the critical context) diff --git a/KompasMcp.slnx b/KompasMcp.slnx index a3a10ac..3c7191b 100644 --- a/KompasMcp.slnx +++ b/KompasMcp.slnx @@ -1,6 +1,5 @@ - diff --git a/README.md b/README.md index 694e123..798e884 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,12 @@ Сервер — это COM-клиент КОМПАС-3D: он подключается к установленному КОМПАС и предоставляет LLM набор инструментов для создания документов, построения 3D-деталей (эскиз → выдавливание), -а также **снимок модели** (агент «видит» результат и строит пошагово). +импорта/экспорта STEP, работы со сборками, структурного осмотра модели (`describe_model` — дерево +операций, тела, переменные, топология одним вызовом) и снимка модели (агент «видит» результат). + +Принцип: **MCP транслирует возможности SDK КОМПАС в общие инструменты** (не под конкретную задачу). +Поверх MCP — навык **`.claude/skills/kompas-3d/`** с методикой работы (playbook'и, эвристики, +обкатанные в `usecases/`). > Подробности: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · план — [docs/IMPLEMENTATION_PLAN.md](docs/IMPLEMENTATION_PLAN.md) > · спорные вопросы — [docs/OPEN_QUESTIONS.md](docs/OPEN_QUESTIONS.md) · обзор — [docs/presentation.html](docs/presentation.html) @@ -34,21 +39,28 @@ src/Kompas.Mcp.Host/bin/Release/net8.0-windows/kompas-mcp.exe } ``` -## Инструменты (v1) +## Инструменты (43 инструмента) | Группа | Инструменты | |---|---| | 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_add_line`, `sketch_add_circle`, `sketch_add_rectangle`, `sketch_close` | +| Sketch | `sketch_create`, `sketch_create_on_face`, `sketch_create_on_face_index`, `sketch_add_line`, `sketch_add_circle`, `sketch_add_rectangle`, `sketch_add_axis`, `sketch_close` | | Features | `extrude_boss`, `extrude_cut`, `revolve_boss`, `revolve_cut`, `fillet_edge`, `chamfer_edge`, `fillet_edge_index`, `chamfer_edge_index`, `rebuild` | -| Vision | `model_snapshot` (PNG-снимок активной модели) | -| Query | `get_part_info`, `get_bounding_box`, `list_faces`, `list_edges` | +| Edit | `move_face` (прямое редактирование: сдвинуть грань на N мм вдоль нормали) | +| Inspection | `describe_model`, `list_features`, `list_bodies`, `list_variables`, `describe_face`, `describe_edge`, `measure` | +| Vision | `model_snapshot` (PNG-снимок; fallback — сначала `describe_model`) | +| Query | `get_part_info`, `get_bounding_box`, `list_faces`, `list_edges`, `list_components` | +| Conversion | `import_step`, `export_step` | +| Validation | `validate_part` | ### Типовой сценарий `kompas_connect` → `document_create part` → `sketch_create XOY` → `sketch_add_circle` → -`sketch_close` → `extrude_boss` → `model_snapshot` → (повтор для следующих элементов). +`sketch_close` → `extrude_boss` → `describe_model` → (повтор для следующих элементов). + +> `describe_model` предпочтителен перед `model_snapshot`: отдаёт структурный «паспорт» детали +> (дерево операций, тела, переменные, топологию, МЦХ) без расхода токенов на изображение. > ⚠️ Вызовы зависимых инструментов выполняйте **последовательно** (дождавшись ответа): > сессия построения хранит эскизы по `id`, а сервер обрабатывает запросы конкурентно. @@ -56,12 +68,13 @@ src/Kompas.Mcp.Host/bin/Release/net8.0-windows/kompas-mcp.exe ## Структура ``` -src/Kompas.Mcp.Core/ COM-слой: STA-диспетчер, подключение, документы, эскизы/операции, снимок +src/Kompas.Mcp.Core/ COM-слой: STA-диспетчер, подключение, документы, эскизы/операции, конвертация, снимок, инспекция модели src/Kompas.Mcp.Host/ MCP-сервер (stdio) + определения инструментов -tests/Kompas.Mcp.Tests/ unit (диспетчер) + integration (требуют КОМПАС) +tests/Kompas.Mcp.Tests/ 60 тестов: unit + integration (integration требуют КОМПАС) libs/kompas-interop/ вендорские interop-сборки КОМПАС (из SDK Samples/Common) docs/ архитектура, план, презентация, навигатор по справке SDK -.claude/skills/ навыки: docs-delegate, kompas-sdk-research (+ kdoc.py — навигатор справки SDK) +usecases/ полигон обкатки подходов (в .gitignore); приёмы поднимаются в навык kompas-3d +.claude/skills/ навыки: docs-delegate, kompas-sdk-research (+ kdoc.py), kompas-3d (методика) ``` ## Тесты @@ -76,7 +89,8 @@ dotnet test --filter "Category=Integration" # только integration - **STA-поток** (`KompasDispatcher`) владеет всеми COM-вызовами — КОМПАС однопоточный STA. - Подключение через API5 `KompasObject` (`KOMPAS.Application.5`) → API7 `IApplication` (`ksGetApplication7`). -- 3D строится через API5 `ksPart` (эскизы, выдавливание); снимок — `ksDocument3D.SaveAsToRasterFormat`. +- 3D строится через API5 `ksPart` (эскизы, выдавливание, вращение, скругление/фаска); снимок — `ksDocument3D.SaveAsToRasterFormat`. Прямое редактирование импортированной B-rep (`move_face`) — через API7 `IPart7.FindObjectsByPoint` + `FaceMover`. Структурный осмотр (`describe_model` и др.) — через `ModelInspectionService` (дерево операций via `ksPart.GetFeature()`, тела via `BodyCollection()`, переменные via `VariableCollection()`, измерения via `ksMeasurer`). +- STEP импорт/экспорт — `IKompasDocument3D1.ConvertFromAdditionFormat`/`ConvertToAdditionFormat` через встроенный конвертер. - MCP — официальный C# SDK `ModelContextProtocol`, транспорт stdio, логи в stderr. ## Лицензирование diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ecff77f..d8d2a85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,8 +1,7 @@ # Архитектура MCP-сервера КОМПАС-3D (предлагаемый вариант) -> Статус: **черновик / предлагаемый вариант**, открыт к правкам. -> Назначение: зафиксировать выбор стека и целевую архитектуру решения до начала -> реализации. Сопутствующий контекст по COM API КОМПАС — в [`../CLAUDE.md`](../CLAUDE.md). +> Статус: **реализовано и работает** (v1+v2+STEP/assembly+direct-edit+inspection). Актуализировано по коду. +> Сопутствующий контекст по COM API КОМПАС — в [`../CLAUDE.md`](../CLAUDE.md). > Имена интерфейсов и перечислений сверены со справкой SDK через `.claude/skills/kompas-sdk-research/kdoc.py` (навык `kompas-sdk-research`). --- @@ -108,21 +107,21 @@ MCP client ──stdio──> Host (поток-пул, async) ## 4. Слои решения -В v1 — логические слои (папки/проекты одного solution `KompasMcp.sln`): +Проекты solution `KompasMcp.slnx` (спайк-проекты ConnectSpike/ReconSpike удалены; их механика покрыта тестами): -| Слой | Ответственность | +| Проект | Ответственность | |---|---| -| **Kompas.Mcp.Host** | Точка входа. Сборка MCP-сервера (stdio), DI, логи в stderr, старт и владение STA-потоком, graceful shutdown. | -| **Kompas.Mcp.Tools** | Определения MCP-инструментов по категориям (System / Documents / Sketch / Features / Query). **Тонкий слой**: валидация аргументов → постановка задачи на STA-поток → форматирование результата/ошибки в MCP-ответ. | -| **Kompas.Mcp.Core** (`KompasSession`) | COM-слой. Менеджер соединения, менеджер документов, построитель эскизов, построитель операций, помощники выборки граней/плоскостей. Безопасные типизированные методы, null-проверки, трансляция ошибок. Здесь живут все паттерны работы с API5/API7. | -| **Kompas.Mcp.Interop** | Interop-сборки из `.tlb` + маппинг «дружелюбная строка ↔ перечисление» (`DocumentTypeEnum`, `Obj3dType`, `ksEndTypeEnum`, `ksDirectionTypeEnum`). | +| **Kompas.Mcp.Host** | Точка входа. Сборка MCP-сервера (stdio), DI (в т.ч. `ConversionService`), логи в stderr, старт и владение STA-потоком, graceful shutdown. Определения инструментов по категориям (System / Documents / Sketch / Features / Query / Conversion). **Тонкий слой**: валидация аргументов → постановка задачи на STA-поток → форматирование результата/ошибки в MCP-ответ. | +| **Kompas.Mcp.Core** | COM-слой. Менеджер соединения, менеджер документов, построитель эскизов и операций, `ConversionService` (STEP импорт/экспорт), `QueryService` (МЦХ, грани, рёбра, компоненты), `ModelInspectionService` (дерево операций, тела, переменные, drill-down, измерения), снимок. Все паттерны API5/API7 живут здесь. | +| **Kompas.Mcp.Tests** | Юнит (без COM) + интеграционные (требуют КОМПАС). 60 тестов. | -Принцип: **только `Core` и `Interop` знают про COM**. `Tools` оперируют доменными -DTO и вызывают `Core`; `Host` не содержит бизнес-логики. +Принцип: **только `Core` знает про COM**. `Host`/Tools оперируют доменными DTO и вызывают `Core`; `Host` не содержит бизнес-логики. + +Архитектурный принцип: **MCP транслирует возможности SDK КОМПАС в общие инструменты** (не под конкретную задачу). Методический слой поверх MCP — навык `.claude/skills/kompas-3d/` (playbook'и, эвристики). Полигон отработки подходов — `usecases/` (в .gitignore). --- -## 5. Карта инструментов v1 +## 5. Карта инструментов (43 инструмента) Сгруппированы вокруг центрального цикла «эскиз ↔ операция». Имена — `snake_case`. @@ -147,14 +146,37 @@ DTO и вызывают `Core`; `Host` не содержит бизнес-лог - `revolve_boss` / `revolve_cut` — угол. - `rebuild` — перестроить деталь. -**Selection / Query** -- `select_face_by_point` — выбрать грань точкой (для следующего эскиза). -- `list_bodies`, `list_faces_planes` — перечислить тела/грани и плоскости. -- `get_part_info` — МЦХ/габариты (`IMassInertiaParam7`). +**Edit** (прямое редактирование) +- `move_face` — сдвинуть грань по мировой точке (x,y,z, мм) на distance мм вдоль нормали; distance>0 — наружу (добавить материал), <0 — внутрь. Работает на импортированной B-rep. Реализовано через API7 `IPart7.FindObjectsByPoint` + `FaceMover`; контейнеры `ISurfaceContainer`/`IModelContainer` получаются COM-QI от `IPart7` в рантайме. Проверено: top_spacer 39.45→41.45 мм. -**Vision — визуальная обратная связь («зрение агента»)** +**Selection / Query** +- `get_part_info` — МЦХ через API5 `ksPart.CalcMassInertiaProperties(ST_MIX_MM|ST_MIX_KG)`. +- `get_bounding_box` — габарит (`ksPart.GetGabarit`). +- `list_faces` / `list_edges` — перечислить грани и рёбра детали с классификацией и мерами. +- `list_components` — перечислить компоненты верхнего уровня сборки (индекс, имя, обозначение, признак детали, габарит) через `TopPart → IParts7`. + +**Conversion** (`ConversionTools.cs`, `ConversionService.cs`) +- `import_step` — импорт .step/.stp в новый документ через встроенный конвертер КОМПАС (код `ksConverterFromSTEP=-3`); параметры `type` (assembly|part), `createComponentFiles`. +- `export_step` — экспорт активного 3D-документа в STEP; `format` = auto|ap203|ap214|ap242. + +**Inspection — структурный осмотр модели (приоритетный над снимком)** +- `describe_model` — структурный «паспорт» детали одним вызовом: габарит, МЦХ, тела, сводка топологии (грани/рёбра по типам), дерево построения с параметрами, переменные. **Предпочитать перед `model_snapshot`** — не тратит токены изображения. +- `list_features` — дерево построения с параметрами (глубина/радиус/катеты), имена узлов локализованы КОМПАС. +- `list_bodies` — тела детали: тип (твёрдое/поверхность) и число граней. +- `list_variables` — переменные модели (имя/выражение/значение, флаги external/информационная). +- `describe_face` — drill-down грани по индексу: тип, площадь, нормаль, радиус, число рёбер. +- `describe_edge` — drill-down ребра по индексу: тип, длина, смежные грани, концевые вершины. +- `measure` — расстояние и угол между двумя объектами (face|edge|vertex по индексам), единица `ST_MIX_MM`. + +Реализовано через `ModelInspectionService` (`src/Kompas.Mcp.Core/Query/ModelInspectionService.cs`), +рендер текста — `InspectionText` (`InspectionText.cs`, юнит-тестируем без COM). +Ключевые API-паттерны: `ksPart.GetFeature()` → `ksFeatureCollection` (дерево); `BodyCollection()` → `ksBody`; `VariableCollection()` → `ksVariable`; `GetMeasurer()` → `ksMeasurer`. +«Импорт STEP без истории» определяется структурно: `bodyCount > 0 && нет формообразующих операций && features.Count <= bodyCount+1`. + +**Vision — визуальная обратная связь (fallback)** - `model_snapshot` — отрендерить активный 3D-документ в PNG и вернуть **как image-контент MCP**, - чтобы мультимодальный агент *увидел* промежуточный результат и контролировал построение пошагово. + чтобы мультимодальный агент *увидел* промежуточный результат. Использовать для визуально-пространственных + вопросов, когда структурный осмотр (`describe_model`) недостаточен. - `set_view` — задать ориентацию вида (`iso` / `front` / `top` / `right` / …), zoom-to-fit и режим отображения (`ShadedWireframe`) перед снимком — иначе снимок берётся со случайного текущего ракурса. @@ -282,24 +304,24 @@ dotnet build -c Release -r win-x64 # сборка --- -## 10. Дорожная карта (после v1) +## 10. Дорожная карта -1. Операции: `fillet` / `chamfer` / `shell` / `rib` / `loft` / `sweep`; массивы - (`ILinearPattern`, `ICircularPattern`). -2. Параметры и свойства: `IVariable7` / `IVariableTable`, `IPropertyMng` / `IPropertyKeeper`. -3. Полноценное 2D-черчение: виды, линии/дуги/окружности, размеры (`ILinearDimension`, …), - штриховки, тексты. -4. Спецификации (`ISpecification`), сборки и сопряжения (`IAssemblyDocument`). -5. Конвертеры/экспорт (STEP и др.) через `IApplication.Converter`. +**Реализовано (v1+v2+STEP/assembly+direct-edit+inspection):** документы, эскизы, выдавливание/вырез, вращение, скругление/фаска, снимок; `get_part_info`, `get_bounding_box`, `list_faces`, `list_edges`; `import_step`, `export_step`, `list_components`; **`move_face`** (прямое редактирование грани, работает на импортированной B-rep); **`describe_model`, `list_features`, `list_bodies`, `list_variables`, `describe_face`, `describe_edge`, `measure`** (структурный осмотр модели). + +**Следующие приоритеты:** +1. Рассечение/перемещение тела как MCP-инструменты (`SplitSolids`/`BodyRepositions`) — механика есть, продуктизация не закончена. +2. Массивы (`ILinearPattern`, `ICircularPattern`). +3. Свойства документа: `IPropertyMng` / `IPropertyKeeper`. +4. Полноценное 2D-черчение: виды, линии/дуги/окружности, размеры (`ILinearDimension`, …), штриховки, тексты. +5. Спецификации (`ISpecification`), построение сборок и сопряжения. 6. Транспорт **HTTP/SSE** — как опция для удалённых клиентов. --- ## 11. Открытые вопросы -- **Один проект vs solution из 4 проектов** в v1 (склоняемся к одному solution, слои — папками, - выделять проекты по мере роста). -- **Embed Interop Types vs генерируемые interop-сборки** (`tlbimp`) — проверить на реальных - `.tlb` v24, выбрать по совместимости типов и размеру. -- **Стратегия именования инструментов** — подтвердить `snake_case` с группами-префиксами - (`document_*`, `sketch_*`, …). +- **Прямое редактирование B-rep, перемещение грани**: решено — `move_face` (`FaceEditService`) работает через `IPart7.FindObjectsByPoint` + `FaceMover`; проверено на импортированной B-rep. Рассечение/перемещение тела (`SplitSolids`/`BodyRepositions`) — механика есть, инструменты не реализованы (потенциальная следующая опция). +- **Дерево операций, тела, переменные, геометрические запросы**: решено — `ModelInspectionService` + `InspectionTools` (7 инструментов); юнит-тестируемый рендер через `InspectionText`. +- **Утечки транзитных RCW** в `PartModeler` (унаследованный паттерн): `EntityCollection`, `GetTopPart()` не освобождаются, накапливаются за длинную сессию. +- **Насос сообщений / CancellationToken**: `CancellationToken` не прерывает идущий COM-вызов; зависший модальный диалог КОМПАС блокирует всю очередь. +- **Embed Interop Types vs вендорские interop-сборки**: выбраны вендорские DLL из SDK `Samples/Common`; менять не планируется. diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md index c1fb224..31e2d62 100644 --- a/docs/OPEN_QUESTIONS.md +++ b/docs/OPEN_QUESTIONS.md @@ -112,3 +112,32 @@ stateful-сессии? Пока полагаемся на последовате `SnapshotService` рендерит во временный файл и читает байты обратно (надёжно проверено на v24 Home). **На проработку:** пустое имя файла + `returnResultAsArrayBytes=true` для чистого in-memory — не проверено; текущий путь через temp-файл работает. + +### ✅ STEP импорт/экспорт и list_components — реализованы +`import_step`, `export_step` (`ConversionService`, `ConversionTools`), `list_components` (`QueryService.ListComponentsAsync`) добавлены в v2+. +Проверенные COM-приёмы: +- Импорт: `IApplication.get_Converter((object)(int)ksConverterFromSTEP=-3)` (код формата, не путь к DLL) → `IConverter.ConverterParameters(cmd)` → `IAdditionConvertParameters.Format=ksConverterFromSTEP` → `IKompasDocument3D1.ConvertFromAdditionFormat(path, prm)`. +- Экспорт: `ConvertToAdditionFormat` с форматами AP203/AP214/AP242 (`StepFormat` enum в `Core/Conversion/StepFormat.cs`). +- Param-коклассы (AdditionConvertParameters) — **не** CoCreatable, только через фабрики. +- Обход сборки: `IKompasDocument3D.TopPart → IPart7.Parts (IParts7)`. +- Извлечение детали: `prm.NeedCreateComponentsFiles=true` + `IPart7.OpenSourceDocument`. + +### ✅ Прямое редактирование импортированной B-rep — move_face реализован +**Решено.** Перемещение грани продуктизировано как инструмент `move_face` / сервис `FaceEditService` (`src/Kompas.Mcp.Core/Editing/FaceEditService.cs`). Зарегистрирован в DI (`Program.cs`); интеграционный тест — `Integration/FaceEditTests.cs`. + +Проверенный паттерн: +- Объект грани берётся через **`IPart7.FindObjectsByPoint(x, y, z, true)`** → результат приводится к `KompasAPI7.IFace`. Не требует `EntityCollection`/`SelectByPoint` через API5. +- Контейнеры операций не создаются отдельно: `(ISurfaceContainer)part` и `(IModelContainer)part` — COM-QI от `IPart7` в рантайме. +- `FaceMover`: `SetFaces(faces)` + `Offset(distance)` + `Direction(true/false = наружу/внутрь)` + `Update()`. +- Работает как на параметрической геометрии, так и на импортированной B-rep (проверено: деталь top_spacer, высота 39.45 → 41.45 мм). + +Полный «рассечь→раздвинуть→объединить» (`SplitSolids`/`BodyRepositions`) — механика есть и драйвится, но отдельными MCP-инструментами не оформлена (возможная будущая опция). + +### ✅ Структурный осмотр модели — инспекция реализована +**Решено.** 7 инструментов (`InspectionTools.cs`, `ModelInspectionService.cs`, `InspectionText.cs`): +- `describe_model` — структурный «паспорт» детали: габарит, МЦХ, тела, сводка топологии, дерево операций с параметрами, переменные. **Предпочтителен перед `model_snapshot`**. +- `list_features` / `list_bodies` / `list_variables` — детальные срезы. +- `describe_face` / `describe_edge` — drill-down по индексу. +- `measure` — расстояние/угол между объектами (`ksMeasurer`). + +Ключевые паттерны: дерево — `ksPart.GetFeature()` → `SubFeatureCollection(true,false)`; тела — `BodyCollection()` → `ksBody`; переменные — `VariableCollection()` → `ksVariable`. Детект «импорт без истории»: `bodyCount>0 && нет формообразующих операций && features.Count<=bodyCount+1`. Итог: **43 инструмента, 60 тестов.** diff --git a/docs/presentation.html b/docs/presentation.html index 9d51ab9..7df81cd 100644 --- a/docs/presentation.html +++ b/docs/presentation.html @@ -125,7 +125,7 @@
- Статус: v2 работает — цикл эскиз→операции→снимок через MCP (31 инструмент) + Статус: v3 работает — эскиз→операции→STEP→сборка→move_face→структурный осмотр (43 инструмента)

КОМПАС-3D MCP-сервер
управление CAD языком LLM

MCP-сервер, который превращает операции КОМПАС-3D — создание документов, эскизы, 3D-операции, параметры — в инструменты для языковой модели. Под капотом — @@ -173,24 +173,36 @@

Выдавливание и вырез, вращение, перестроение. Управление направлением, глубиной и типом окончания (ksEndTypeEnum).

+
+
🔍
+

Структурный осмотр

+

describe_model — паспорт детали (МЦХ, тела, дерево, переменные) одним вызовом. + describe_face · describe_edge · measure. + Предпочтительнее снимка — без расхода токенов изображения.

+
🎯

Выборка и запросы

-

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

+

Перечисление граней, рёбер и компонентов сборки с классификацией; МЦХ, габарит. + list_faces · list_edges · list_components.

+
+
+
📦
+

STEP импорт / экспорт

+

Импорт .step/.stp в новый документ (деталь или сборка, создание файлов компонентов); + экспорт в AP203/AP214/AP242 через встроенный конвертер КОМПАС.

+
+
+
✂️
+

Прямое редактирование

+

move_face: сдвинуть грань на N мм вдоль нормали (наружу или внутрь). + Работает на импортированной B-rep через FaceMover API7.

👁️

Зрение агента

-

Рендер модели в PNG прямо в память (resultArrayBytes) и возврат как - image-контент MCP — агент видит результат и строит пошагово. Плюс set_view: - ориентация и zoom-to-fit перед снимком.

-
-
-
🛟
-

Escape-hatch

-

Прямой вызов команд КОМПАС через ExecuteKompasCommand для того, - что ещё не обёрнуто в типизированные инструменты.

+

Рендер модели в PNG через временный файл и возврат как image-контент MCP — агент видит + результат. Fallback после describe_model для визуально-пространственных вопросов.

@@ -246,8 +258,8 @@ впереди.

-
Общий прогрессv2: грань · вращение · скругление/фаска · габариты · список граней/рёбер · 19 тестов
-
+
Общий прогрессv3: STEP · list_components · move_face · структурный осмотр · навык kompas-3d · 43 инструмента · 60 тестов
+
@@ -281,16 +293,19 @@
-

Инструменты v1 ГОТОВО

-

31 инструмент, отдаются по MCP-протоколу

+

Инструменты v3 ГОТОВО

+

43 инструмента, отдаются по MCP-протоколу

  • System: connect · status · set_visible
  • Documents: create · open · save · save_as · close · active
  • Sketch: create · on_face (точка/индекс) · add line/circle/rectangle/axis · close
  • -
  • Features: extrude · revolve · fillet_edge/chamfer_edge (точка) · fillet_edge_index/chamfer_edge_index (индекс) 🆕 · rebuild
  • -
  • Vision: model_snapshot 👁️
  • -
  • Query: get_part_info · get_bounding_box · list_faces · list_edges 🆕
  • -
  • Query: list_bodies (далее)
  • +
  • Features: extrude · revolve · fillet_edge/chamfer_edge (точка + индекс) · rebuild
  • +
  • Edit: move_face (прямое редактирование B-rep)
  • +
  • Inspection: describe_model · list_features · list_bodies · list_variables · describe_face · describe_edge · measure 🆕
  • +
  • Vision: model_snapshot 👁️ (fallback)
  • +
  • Query: get_part_info · get_bounding_box · list_faces · list_edges · list_components
  • +
  • Conversion: import_step · export_step
  • +
  • Массивы/паттерны · рассечение/перемещение тела (далее)
@@ -299,10 +314,11 @@

Качество и поставка В РАБОТЕ

Тесты, сборка, интеграция с клиентом

    -
  • Unit-тесты диспетчера (5) — без COM
  • -
  • Интеграционные тесты с КОМПАС (7): подключение, документы, снимок, цикл
  • -
  • Сквозной прогон по MCP-протоколу (tools/call → PNG)
  • +
  • Unit-тесты — без COM: диспетчер, enum-маппинг, StepFormat, InspectionText
  • +
  • Интеграционные тесты с КОМПАС: подключение, документы, снимок, цикл, STEP round-trip, сборка, move_face, инспекция
  • +
  • Итого 60 тестов зелёных
  • Сборка kompas-mcp.exe, README с конфигом клиента
  • +
  • Навык kompas-3d (методика) + полигон usecases/
  • In-process тест MCP-клиента, релизный publish
@@ -318,11 +334,11 @@

Наращиваем покрытие API волнами — от простых операций к параметрике, черчению и сборкам.

-
v1 · готово ✓

Цикл моделирования

Документы, эскизы, выдавливание/вырез, снимок модели — работает

-
v2 · в работе

Операции и выборка

Эскиз на грани ✓ · вращение ✓ · скругление/фаска (точка + индекс) ✓ · список граней/рёбер ✓ · далее: массивы · list_bodies

-
v3

Параметрика

Переменные IVariable7, свойства IPropertyMng, формулы

-
v4

2D-черчение

Виды, размеры, тексты, штриховки; спецификации

-
v5

Сборки и экспорт

Компоненты, сопряжения, конвертеры (STEP), HTTP/SSE-транспорт

+
v1 · готово ✓

Цикл моделирования

Документы, эскизы, выдавливание/вырез, снимок модели

+
v2 · готово ✓

Операции, выборка, STEP

Эскиз на грани · вращение · скругление/фаска · list_faces/edges · import_step · export_step · list_components · навык kompas-3d

+
v3 · готово ✓

B-rep + структурный осмотр

move_face (сдвиг грани на N мм) · describe_model · list_features · list_bodies · list_variables · describe_face · describe_edge · measure

+
v4 · в работе

Массивы + 2D

Массивы/паттерны · рассечение тела · виды, размеры, штриховки; свойства документа

+
v5

Сборки и транспорт

Построение сборок, сопряжения; HTTP/SSE-транспорт

diff --git a/src/Kompas.Mcp.ConnectSpike/Kompas.Mcp.ConnectSpike.csproj b/src/Kompas.Mcp.ConnectSpike/Kompas.Mcp.ConnectSpike.csproj deleted file mode 100644 index 5c04bcb..0000000 --- a/src/Kompas.Mcp.ConnectSpike/Kompas.Mcp.ConnectSpike.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - - Exe - kompas-connect-spike - Kompas.Mcp.ConnectSpike - - - - - - $(KompasInteropDir)\KompasAPI7.dll - true - false - - - $(KompasInteropDir)\Kompas6API5.dll - true - false - - - $(KompasInteropDir)\Kompas6Constants.dll - true - false - - - $(KompasInteropDir)\Kompas6Constants3D.dll - true - false - - - - diff --git a/src/Kompas.Mcp.ConnectSpike/Program.cs b/src/Kompas.Mcp.ConnectSpike/Program.cs deleted file mode 100644 index 67ffba0..0000000 --- a/src/Kompas.Mcp.ConnectSpike/Program.cs +++ /dev/null @@ -1,137 +0,0 @@ -// Спайк S1+S2: проверка interop-сборок и подключения к КОМПАС-3D. -// -// Доказывает, что: -// * вендорские interop-сборки (KompasAPI7 / Kompas6API5) собираются и загружаются под net8.0-windows; -// * клиент умеет attach к запущенному КОМПАС и launch нового экземпляра; -// * получает IApplication (API7), делает окно видимым и читает версию. -// -// Внимание: Marshal.GetActiveObject отсутствует в .NET 5+, поэтому attach делается -// через нативные CLSIDFromProgID + GetActiveObject (oleaut32). -// -// Логи -> stderr, итог -> stdout. Код возврата 0 = успех. - -using System.Runtime.InteropServices; -using System.Runtime.Versioning; -using Kompas6API5; -using KompasAPI7; - -namespace Kompas.Mcp.ConnectSpike; - -[SupportedOSPlatform("windows")] -internal static class Program -{ - // Порядок попыток подключения. На v24 Home проверено: есть .7 и .5, нет LT. - private static readonly string[] ProgIds = - { - "KOMPAS.Application.7", // прямой API7 - "KOMPAS.Application.5", // API5 -> ksGetApplication7() - "KOMPASLT.Application.5", // запасной (LT-инсталляции) - }; - - private static int Main() - { - Log("=== KOMPAS connect spike ==="); - bool launchedByUs = false; - IApplication? app = null; - - // 1) attach к уже запущенному экземпляру - foreach (var id in ProgIds) - { - var root = AttachByProgId(id); - if (root is null) continue; - app = AsApplication(root); - if (app != null) { Log($"attached: {id}"); break; } - } - - // 2) запустить новый экземпляр - if (app is null) - { - foreach (var id in ProgIds) - { - var root = CreateByProgId(id); - if (root is null) continue; - app = AsApplication(root); - if (app != null) { launchedByUs = true; Log($"launched: {id}"); break; } - } - } - - if (app is null) - { - Log("РЕЗУЛЬТАТ: подключиться к КОМПАС не удалось."); - return 1; - } - - app.Visible = true; - Console.WriteLine("CONNECTED"); - ReportVersion(app); - - Log(launchedByUs - ? "Экземпляр запущен этим процессом (в реальном сервере здесь решаем про Quit при выходе)." - : "Подключились к уже запущенному экземпляру (Quit не вызываем)."); - Log("Готово. Окно КОМПАС должно быть видимым."); - return 0; - } - - /// Привести COM-корень к IApplication: API7 — прямой каст, API5 — через ksGetApplication7(). - private static IApplication? AsApplication(object root) - { - if (root is IApplication direct) - return direct; - - if (root is KompasObject ko) - { - try { return ko.ksGetApplication7() as IApplication; } - catch (Exception ex) { Log($"ksGetApplication7 failed: {ex.Message}"); } - } - - try { return (IApplication)root; } - catch { return null; } - } - - private static void ReportVersion(IApplication app) - { - try { Console.WriteLine($"BuildNumber: {app.BuildNumber}"); } - catch (Exception ex) { Log($"BuildNumber failed: {ex.Message}"); } - - try - { - int major = 0, minor = 0, release = 0, build = 0; - app.GetSystemVersion(out major, out minor, out release, out build); - Console.WriteLine($"Version: {major}.{minor}.{release} (build {build})"); - } - catch (Exception ex) { Log($"GetSystemVersion failed: {ex.Message}"); } - } - - private static object? AttachByProgId(string progId) - { - try - { - CLSIDFromProgID(progId, out Guid clsid); - GetActiveObject(ref clsid, IntPtr.Zero, out object obj); - return obj; - } - catch - { - return null; // не запущен / не зарегистрирован - } - } - - private static object? CreateByProgId(string progId) - { - var type = Type.GetTypeFromProgID(progId, throwOnError: false); - if (type is null) return null; - try { return Activator.CreateInstance(type); } - catch (Exception ex) { Log($"CreateInstance({progId}) failed: {ex.Message}"); return null; } - } - - [DllImport("ole32.dll", PreserveSig = false)] - private static extern void CLSIDFromProgID( - [MarshalAs(UnmanagedType.LPWStr)] string progId, out Guid clsid); - - [DllImport("oleaut32.dll", PreserveSig = false)] - private static extern void GetActiveObject( - ref Guid rclsid, IntPtr pvReserved, - [MarshalAs(UnmanagedType.IUnknown)] out object ppunk); - - private static void Log(string msg) => Console.Error.WriteLine(msg); -} diff --git a/src/Kompas.Mcp.Core/Conversion/ConversionService.cs b/src/Kompas.Mcp.Core/Conversion/ConversionService.cs new file mode 100644 index 0000000..58052e9 --- /dev/null +++ b/src/Kompas.Mcp.Core/Conversion/ConversionService.cs @@ -0,0 +1,143 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Threading; +using Kompas6Constants; +using KompasAPI7; + +namespace Kompas.Mcp.Core.Conversion; + +/// +/// Импорт/экспорт обменных форматов (STEP) через КОМПАС-конвертер. +/// +/// Встроенный конвертер получается через IApplication.get_Converter(код_формата) +/// (целочисленный код ksKOMPASConverterEnum, НЕ путь к DLL), параметры — через +/// IConverter.ConverterParameters, сама конвертация — через +/// IKompasDocument3D1.ConvertTo/FromAdditionFormat. +/// +/// +[SupportedOSPlatform("windows")] +public sealed class ConversionService +{ + private readonly KompasSession _session; + private readonly KompasDispatcher _dispatcher; + + public ConversionService(KompasSession session, KompasDispatcher dispatcher) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + /// + /// Импортировать STEP-файл в новый документ заданного типа (по умолчанию — сборка) и сделать его активным. + /// — писать ли компоненты сборки отдельными .m3d рядом с STEP. + /// + public Task ImportStepAsync( + string path, + KompasDocumentType docType = KompasDocumentType.Assembly, + bool createComponentFiles = false, + CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => ImportCore(path, docType, createComponentFiles), ct); + + private bool ImportCore(string path, KompasDocumentType docType, bool createComponentFiles) + { + if (!File.Exists(path)) + throw new FileNotFoundException($"STEP-файл не найден: {path}", path); + + var app = _session.Application; + var typeEnum = DocumentTypes.ToEnum(docType); + IConverter? conv = null; + IAdditionConvertParameters? prm = null; + object? doc = null; + try + { + conv = GetConverter(app, StepFormats.ImportConverterCode); // -3 + conv.GetFilter((int)typeEnum, false /* импорт */, out int cmd); + + doc = app.Documents.Add(typeEnum, true); + if (doc is not IKompasDocument3D1 doc3d1) + throw new InvalidOperationException("Созданный документ не является 3D-документом (нет IKompasDocument3D1)."); + + prm = conv.ConverterParameters(cmd) as IAdditionConvertParameters + ?? throw new InvalidOperationException("ConverterParameters вернул не IAdditionConvertParameters."); + prm.Format = (ksKOMPASConverterEnum)StepFormats.ImportConverterCode; + if (createComponentFiles) + { + try { prm.NeedCreateComponentsFiles = true; } + catch { /* свойство может быть недоступно — не критично */ } + } + + if (!doc3d1.ConvertFromAdditionFormat(path, (AdditionConvertParameters)prm)) + throw new InvalidOperationException("ConvertFromAdditionFormat вернул FALSE (импорт STEP не удался)."); + return true; + } + finally + { + ReleaseCom(prm); + ReleaseCom(doc); + ReleaseCom(conv); + } + } + + /// Экспортировать активный 3D-документ в STEP по указанному пути. + public Task ExportStepAsync(string path, StepFormat format = StepFormat.Auto, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => ExportCore(path, format), ct); + + private bool ExportCore(string path, StepFormat format) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Путь экспорта не задан.", nameof(path)); + + var app = _session.Application; + object? active = null; + IConverter? conv = null; + IAdditionConvertParameters? prm = null; + try + { + active = app.ActiveDocument + ?? throw new InvalidOperationException("Нет активного документа для экспорта."); + if (active is not IKompasDocument3D1 doc3d1) + throw new InvalidOperationException("Активный документ не является 3D-документом (нет IKompasDocument3D1)."); + + int formatCode = StepFormats.ExportConverterCode(format); + conv = GetConverter(app, formatCode); + conv.GetFilter((int)DocumentTypeEnum.ksDocumentPart, true /* saveAs = экспорт */, out int cmd); + + prm = conv.ConverterParameters(cmd) as IAdditionConvertParameters + ?? throw new InvalidOperationException("ConverterParameters вернул не IAdditionConvertParameters."); + prm.Format = (ksKOMPASConverterEnum)formatCode; + + if (!doc3d1.ConvertToAdditionFormat(path, (AdditionConvertParameters)prm)) + throw new InvalidOperationException("ConvertToAdditionFormat вернул FALSE (экспорт STEP не удался)."); + return true; + } + finally + { + ReleaseCom(prm); + ReleaseCom(active); + ReleaseCom(conv); + } + } + + /// + /// Получить встроенный конвертер. Для STEP get_Converter принимает целочисленный код формата + /// (boxed int), НЕ путь к DLL. Пробуем код формата, затем базовый код экспорта STEP (3). + /// + private static IConverter GetConverter(IApplication app, int formatCode) + { + if (app.Converter[(object)formatCode] is IConverter c) return c; + // AP-вариант мог не быть отдельным конвертером — берём базовый STEP-экспорт (ksConverterToSTEP = 3). + if (app.Converter[(object)3] is IConverter cBase) return cBase; + throw new InvalidOperationException( + $"get_Converter({formatCode}) вернул null — STEP-конвертер недоступен в этой редакции КОМПАС."); + } + + private static void ReleaseCom(object? comObject) + { + if (comObject is not null && Marshal.IsComObject(comObject)) + { + try { Marshal.ReleaseComObject(comObject); } + catch { /* освобождение не критично */ } + } + } +} diff --git a/src/Kompas.Mcp.Core/Conversion/StepFormat.cs b/src/Kompas.Mcp.Core/Conversion/StepFormat.cs new file mode 100644 index 0000000..120bbe8 --- /dev/null +++ b/src/Kompas.Mcp.Core/Conversion/StepFormat.cs @@ -0,0 +1,42 @@ +namespace Kompas.Mcp.Core.Conversion; + +/// Вариант стандарта STEP для экспорта. +public enum StepFormat +{ + /// Формат по умолчанию (ksConverterToSTEP). + Auto, + Ap203, + Ap214, + Ap242, +} + +/// +/// Маппинг формата STEP в целочисленные коды конвертера КОМПАС (ksKOMPASConverterEnum). +/// Эти коды передаются в IApplication.get_Converter и IAdditionConvertParameters.Format. +/// +public static class StepFormats +{ + /// Код импорта STEP — ksConverterFromSTEP. + public const int ImportConverterCode = -3; + + /// Код экспорта STEP для выбранного стандарта (ksConverterToSTEP[_APxxx]). + public static int ExportConverterCode(StepFormat format) => format switch + { + StepFormat.Auto => 3, + StepFormat.Ap203 => 203, + StepFormat.Ap214 => 214, + StepFormat.Ap242 => 242, + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Неизвестный формат STEP."), + }; + + /// Разобрать строку формата ("auto"/"step", "ap203", "ap214", "ap242"). + public static StepFormat Parse(string? value) => value?.Trim().ToLowerInvariant() switch + { + null or "" or "auto" or "step" => StepFormat.Auto, + "ap203" or "203" => StepFormat.Ap203, + "ap214" or "214" => StepFormat.Ap214, + "ap242" or "242" => StepFormat.Ap242, + _ => throw new ArgumentException( + $"Неизвестный формат STEP '{value}'. Допустимо: auto, ap203, ap214, ap242.", nameof(value)), + }; +} diff --git a/src/Kompas.Mcp.Core/Editing/BodyBox.cs b/src/Kompas.Mcp.Core/Editing/BodyBox.cs new file mode 100644 index 0000000..1d74560 --- /dev/null +++ b/src/Kompas.Mcp.Core/Editing/BodyBox.cs @@ -0,0 +1,17 @@ +namespace Kompas.Mcp.Core.Editing; + +/// Габарит тела (по индексу в наборе тел детали) — для выбора тела в операциях редактирования. +public sealed record BodyBox +{ + public required int Index { get; init; } + public required double MinX { get; init; } + public required double MinY { get; init; } + public required double MinZ { get; init; } + public required double MaxX { get; init; } + public required double MaxY { get; init; } + public required double MaxZ { get; init; } + + public double SizeX => Math.Abs(MaxX - MinX); + public double SizeY => Math.Abs(MaxY - MinY); + public double SizeZ => Math.Abs(MaxZ - MinZ); +} diff --git a/src/Kompas.Mcp.Core/Editing/FaceEditService.cs b/src/Kompas.Mcp.Core/Editing/FaceEditService.cs new file mode 100644 index 0000000..0fcd200 --- /dev/null +++ b/src/Kompas.Mcp.Core/Editing/FaceEditService.cs @@ -0,0 +1,84 @@ +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Threading; +using KompasAPI7; + +namespace Kompas.Mcp.Core.Editing; + +/// +/// Прямое редактирование тела: перемещение грани (КОМПАС «Переместить грань», FaceMover). +/// Работает и на импортированной «тупой» B-rep геометрии без дерева построения. +/// +[SupportedOSPlatform("windows")] +public sealed class FaceEditService +{ + private readonly KompasSession _session; + private readonly KompasDispatcher _dispatcher; + + public FaceEditService(KompasSession session, KompasDispatcher dispatcher) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + /// + /// Переместить грань активной детали, найденную по мировой точке (,,), + /// на мм вдоль её нормали. Направление подбирается автоматически + /// (наружу — добавление материала); если КОМПАС отвергает направление, пробуется обратное. + /// + public Task MoveFaceAsync(double x, double y, double z, double distance, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => MoveFaceCore(x, y, z, distance), ct); + + private bool MoveFaceCore(double x, double y, double z, double distance) + { + if (distance == 0) throw new ArgumentException("Смещение не может быть нулевым.", nameof(distance)); + + if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d) + throw new InvalidOperationException("Активный документ не является 3D-документом."); + if (doc3d.TopPart is not IPart7 top) + throw new InvalidOperationException("Не удалось получить TopPart активного документа."); + + var face = FindFaceAtPoint(top, x, y, z) + ?? throw new InvalidOperationException( + $"В точке ({x}, {y}, {z}) не найдена грань. Сверьте координаты точки на грани (model_snapshot)."); + + var sc = top as ISurfaceContainer + ?? throw new InvalidOperationException("Деталь не приводится к ISurfaceContainer (FaceMover недоступен)."); + + var fm = sc.FaceMovers.Add() + ?? throw new InvalidOperationException("FaceMovers.Add() вернул null."); + if (!fm.SetFaces(new object[] { face }, true)) + throw new InvalidOperationException("FaceMover.SetFaces отверг грань."); + fm.Offset = Math.Abs(distance); + + // Направление: пробуем одно, при отказе — обратное (нормаль грани может смотреть в любую сторону). + fm.Direction = distance > 0; + if (!fm.Update()) + { + fm.Direction = !fm.Direction; + if (!fm.Update()) + throw new InvalidOperationException("FaceMover.Update вернул FALSE для обоих направлений."); + } + + // Перестраиваем, чтобы состояние ошибки операции стало актуальным (проверяется через validate_part). + doc3d.RebuildDocument(); + return true; + } + + private static IFace? FindFaceAtPoint(IPart7 part, double x, double y, double z) + { + var hit = part.FindObjectsByPoint(x, y, z, true); + switch (hit) + { + case IFace f: + return f; + case object[] arr: + foreach (var o in arr) if (o is IFace face) return face; + return null; + case System.Collections.IEnumerable en: + foreach (var o in en) if (o is IFace face) return face; + return null; + default: + return null; + } + } +} diff --git a/src/Kompas.Mcp.Core/Editing/SolidEditService.cs b/src/Kompas.Mcp.Core/Editing/SolidEditService.cs new file mode 100644 index 0000000..3aa093b --- /dev/null +++ b/src/Kompas.Mcp.Core/Editing/SolidEditService.cs @@ -0,0 +1,137 @@ +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Threading; +using Kompas6Constants; +using Kompas6Constants3D; +using KompasAPI7; + +namespace Kompas.Mcp.Core.Editing; + +/// +/// Операции над телами импортированной/построенной геометрии: рассечение плоскостью +/// (SplitSolids), перемещение тела (BodyRepositions), объединение (Booleans). +/// Контейнеры операций берутся приведением детали (COM-QI): IModelContainer/IAuxiliaryGeomContainer. +/// +[SupportedOSPlatform("windows")] +public sealed class SolidEditService +{ + private readonly KompasSession _session; + private readonly KompasDispatcher _dispatcher; + + public SolidEditService(KompasSession session, KompasDispatcher dispatcher) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + /// + /// Рассечь тело активной детали смещённой плоскостью (база + мм). + /// Возвращает габариты получившихся тел (с индексами) — по ним выбирают тело для . + /// + public Task> SplitByPlaneAsync( + BasePlane basePlane, double offset, bool direction = true, CancellationToken ct = default) + => _dispatcher.InvokeAsync>(() => + { + var (doc3d, top) = ActivePart(); + var ag = top as IAuxiliaryGeomContainer + ?? throw new InvalidOperationException("Деталь не приводится к IAuxiliaryGeomContainer."); + var plane = (IPlane3DByOffset)ag.Planes3D.Add(ksObj3dTypeEnum.o3d_planeOffset); + plane.BasePlane = top.DefaultObject[PlaneType(basePlane)]; + plane.Offset = offset; + plane.Direction = direction; + if (!plane.Update()) + throw new InvalidOperationException("Не удалось построить секущую плоскость."); + + var mc = ModelContainer(top); + var ss = mc.SplitSolids.Add(); + ss.CutObjects = plane; + if (!ss.Update()) + throw new InvalidOperationException("SplitSolid.Update вернул FALSE (рассечение не удалось)."); + doc3d.RebuildDocument(); + return BodyBoxes(top); + }, ct); + + /// Переместить тело (по индексу из ) на вектор (dx, dy, dz) мм. + public Task MoveBodyAsync(int bodyIndex, double dx = 0, double dy = 0, double dz = 0, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var (doc3d, top) = ActivePart(); + var bodies = ResultBodies(top); + if (bodyIndex < 0 || bodyIndex >= bodies.Count) + throw new ArgumentOutOfRangeException(nameof(bodyIndex), + $"Индекс тела вне диапазона [0; {bodies.Count - 1}]."); + + var mc = ModelContainer(top); + var br = mc.BodyRepositions.Add(); + br.RepositionBody = (IKompasAPIObject)bodies[bodyIndex]; + var pos = br.Position; + pos.SetStartingOrientation(); + pos.X = dx; pos.Y = dy; pos.Z = dz; // смещение начала ЛСК тела = перенос тела + if (!br.Update()) + throw new InvalidOperationException("BodyReposition.Update вернул FALSE."); + doc3d.RebuildDocument(); + }, ct); + + /// Объединить все тела активной детали в одно (булева операция «объединение»). + public Task UnionAllAsync(CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var (doc3d, top) = ActivePart(); + var bodies = ResultBodies(top); + if (bodies.Count < 2) return; // объединять нечего + + var mc = ModelContainer(top); + var bln = mc.Booleans.Add(); + bln.BaseObject = (IKompasAPIObject)bodies[0]; + bln.ModifyObjects = bodies.Skip(1).Cast().ToArray(); + bln.BooleanType = ksBooleanType.ksUnion; + if (!bln.Update()) + throw new InvalidOperationException("Boolean union.Update вернул FALSE."); + doc3d.RebuildDocument(); + }, ct); + + // ---- внутреннее -------------------------------------------------------- + + private (IKompasDocument3D doc, IPart7 top) ActivePart() + { + if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d) + throw new InvalidOperationException("Активный документ не является 3D-документом."); + if (doc3d.TopPart is not IPart7 top) + throw new InvalidOperationException("Не удалось получить TopPart активного документа."); + return (doc3d, top); + } + + private static IModelContainer ModelContainer(IPart7 top) + => top as IModelContainer + ?? throw new InvalidOperationException("Деталь не приводится к IModelContainer."); + + private static ksObj3dTypeEnum PlaneType(BasePlane p) => p switch + { + BasePlane.XOY => ksObj3dTypeEnum.o3d_planeXOY, + BasePlane.XOZ => ksObj3dTypeEnum.o3d_planeXOZ, + BasePlane.YOZ => ksObj3dTypeEnum.o3d_planeYOZ, + _ => throw new ArgumentOutOfRangeException(nameof(p), p, "Неизвестная базовая плоскость."), + }; + + private static List ResultBodies(IPart7 top) + { + var list = new List(); + var rb = ((IFeature7)top).ResultBodies; + if (rb is object[] arr) foreach (var o in arr) { if (o is IBody7 b) list.Add(b); } + else if (rb is System.Collections.IEnumerable en) foreach (var o in en) { if (o is IBody7 b) list.Add(b); } + return list; + } + + private static IReadOnlyList BodyBoxes(IPart7 top) + { + var bodies = ResultBodies(top); + var list = new List(bodies.Count); + for (int i = 0; i < bodies.Count; i++) + { + double x1 = 0, y1 = 0, z1 = 0, x2 = 0, y2 = 0, z2 = 0; + try { bodies[i].GetGabarit(out x1, out y1, out z1, out x2, out y2, out z2); } catch { /* ignore */ } + list.Add(new BodyBox { Index = i, MinX = x1, MinY = y1, MinZ = z1, MaxX = x2, MaxY = y2, MaxZ = z2 }); + } + return list; + } +} diff --git a/src/Kompas.Mcp.Core/Query/BodyInfo.cs b/src/Kompas.Mcp.Core/Query/BodyInfo.cs new file mode 100644 index 0000000..e1bf66d --- /dev/null +++ b/src/Kompas.Mcp.Core/Query/BodyInfo.cs @@ -0,0 +1,14 @@ +namespace Kompas.Mcp.Core.Query; + +/// Сводка по телу детали. +public sealed record BodyInfo +{ + /// Индекс тела в коллекции тел детали. + public required int Index { get; init; } + + /// true — твёрдое (замкнутое) тело. + public required bool IsSolid { get; init; } + + /// Число граней тела. + public required int FaceCount { get; init; } +} diff --git a/src/Kompas.Mcp.Core/Query/ComponentInfo.cs b/src/Kompas.Mcp.Core/Query/ComponentInfo.cs new file mode 100644 index 0000000..9d327d1 --- /dev/null +++ b/src/Kompas.Mcp.Core/Query/ComponentInfo.cs @@ -0,0 +1,25 @@ +namespace Kompas.Mcp.Core.Query; + +/// Сводка по компоненту сборки (деталь или подсборка верхнего уровня). +public sealed record ComponentInfo +{ + /// Индекс в коллекции компонентов верхнего уровня. + public required int Index { get; init; } + + /// Имя компонента в дереве. + public required string Name { get; init; } + + /// Обозначение (marking), если задано. + public required string Marking { get; init; } + + /// Путь к файлу-источнику компонента, если есть. + public required string FileName { get; init; } + + /// true — деталь, false — подсборка. + public required bool IsDetail { get; init; } + + /// Габаритные размеры компонента (мм). 0, если габарит недоступен. + public required double SizeX { get; init; } + public required double SizeY { get; init; } + public required double SizeZ { get; init; } +} diff --git a/src/Kompas.Mcp.Core/Query/EdgeDetail.cs b/src/Kompas.Mcp.Core/Query/EdgeDetail.cs new file mode 100644 index 0000000..adcc3ad --- /dev/null +++ b/src/Kompas.Mcp.Core/Query/EdgeDetail.cs @@ -0,0 +1,25 @@ +namespace Kompas.Mcp.Core.Query; + +/// Детальные сведения о ребре (drill-down по индексу из list_edges). +public sealed record EdgeDetail +{ + public required int Index { get; init; } + + /// Тип кривой: line, circle, arc, ellipse, ellipse_arc, nurbs, other. + public required string Type { get; init; } + + /// Длина ребра, мм. + public required double Length { get; init; } + + /// Тип одной смежной грани (по «плюс»-ориентации), либо null. + public string? AdjacentFace1 { get; init; } + + /// Тип другой смежной грани (по «минус»-ориентации), либо null. + public string? AdjacentFace2 { get; init; } + + /// Координаты начальной вершины (мм), либо null. + public (double X, double Y, double Z)? Start { get; init; } + + /// Координаты конечной вершины (мм), либо null. + public (double X, double Y, double Z)? End { get; init; } +} diff --git a/src/Kompas.Mcp.Core/Query/FaceDetail.cs b/src/Kompas.Mcp.Core/Query/FaceDetail.cs new file mode 100644 index 0000000..8cb59eb --- /dev/null +++ b/src/Kompas.Mcp.Core/Query/FaceDetail.cs @@ -0,0 +1,22 @@ +namespace Kompas.Mcp.Core.Query; + +/// Детальные сведения о грани (drill-down по индексу из list_faces). +public sealed record FaceDetail +{ + public required int Index { get; init; } + + /// Тип поверхности: plane, cylinder, cone, sphere, torus, nurbs, other. + public required string Type { get; init; } + + /// Площадь грани, мм². + public required double Area { get; init; } + + /// Единичная нормаль в центре грани (мировые оси), либо null если недоступна. + public (double X, double Y, double Z)? Normal { get; init; } + + /// Радиус цилиндрической грани, мм (null для нецилиндрических). + public double? Radius { get; init; } + + /// Число рёбер, ограничивающих грань. + public required int EdgeCount { get; init; } +} diff --git a/src/Kompas.Mcp.Core/Query/FeatureInfo.cs b/src/Kompas.Mcp.Core/Query/FeatureInfo.cs new file mode 100644 index 0000000..2e7a7a9 --- /dev/null +++ b/src/Kompas.Mcp.Core/Query/FeatureInfo.cs @@ -0,0 +1,20 @@ +namespace Kompas.Mcp.Core.Query; + +/// Узел дерева построения детали (операция или системный объект). +public sealed record FeatureInfo +{ + /// Порядковый индекс в дереве построения. + public required int Index { get; init; } + + /// Имя узла в дереве (локализовано КОМПАС: «Элемент выдавливания:1», «Скругление:1»…). + public required string Name { get; init; } + + /// + /// Семантический вид операции, определённый по типу её определения: + /// origin, sketch, extrusion, cut_extrusion, revolve, cut_revolve, fillet, chamfer, body, other. + /// + public required string Kind { get; init; } + + /// Ключевые параметры операции для показа («глубина 15.00 мм», «R3.00 мм»), либо null. + public string? Detail { get; init; } +} diff --git a/src/Kompas.Mcp.Core/Query/InspectionText.cs b/src/Kompas.Mcp.Core/Query/InspectionText.cs new file mode 100644 index 0000000..9cf8fc8 --- /dev/null +++ b/src/Kompas.Mcp.Core/Query/InspectionText.cs @@ -0,0 +1,119 @@ +using System.Globalization; +using System.Text; + +namespace Kompas.Mcp.Core.Query; + +/// +/// Чистый (без COM) рендер структурного «паспорта» модели и вспомогательные форматтеры. +/// Вынесено отдельно, чтобы покрыть логику представления юнит-тестами. +/// +public static class InspectionText +{ + // Габарит пустой детали КОМПАС отдаёт «пустышкой» ~2·10³⁰⁸ (double близко к максимуму). + private const double EmptyGabaritThreshold = 1e12; + + /// true — габарит соответствует пустой модели (sentinel-«пустышка» или нулевой размер). + public static bool IsEmptyGabarit(double sizeX, double sizeY, double sizeZ) + { + if (double.IsNaN(sizeX) || double.IsNaN(sizeY) || double.IsNaN(sizeZ)) return true; + if (Math.Abs(sizeX) > EmptyGabaritThreshold || + Math.Abs(sizeY) > EmptyGabaritThreshold || + Math.Abs(sizeZ) > EmptyGabaritThreshold) return true; + return sizeX == 0 && sizeY == 0 && sizeZ == 0; + } + + // Формообразующие операции, наличие которых означает параметрическую историю. + private static readonly string[] OpKinds = + { "sketch", "extrusion", "cut_extrusion", "revolve", "cut_revolve", "fillet", "chamfer" }; + + /// + /// Модель — «голый» импорт без истории построения (типично для STEP): в дереве только начало + /// координат и тело(а), нет ни формообразующих операций, ни иных узлов-правок. + /// + public static bool IsImported(IReadOnlyList features, int bodyCount) + => bodyCount > 0 + && !features.Any(f => OpKinds.Contains(f.Kind)) + && features.Count <= bodyCount + 1; // origin + тело(а), ничего сверх + + /// Сгруппировать типы и вернуть «3 plane, 2 cylinder» (по убыванию числа, тай-брейк по имени). + public static string GroupTypes(IReadOnlyList types) + { + if (types.Count == 0) return "—"; + return string.Join(", ", types + .GroupBy(t => t) + .OrderByDescending(g => g.Count()) + .ThenBy(g => g.Key, StringComparer.Ordinal) + .Select(g => $"{g.Count()} {g.Key}")); + } + + /// Отрендерить «паспорт» модели в читаемый текст. + public static string Render(ModelDescription d) + { + var sb = new StringBuilder(); + sb.AppendLine($"Документ: «{d.Name}» (деталь)"); + + if (d.IsEmpty) + { + sb.Append("Модель пуста: нет тел (габарит не определён)."); + return sb.ToString(); + } + + if (d.Box is { } box) + sb.AppendLine(Inv($"Габарит (Д×Ш×В): {box.SizeX:F2} × {box.SizeY:F2} × {box.SizeZ:F2} мм")); + + if (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)); + + sb.AppendLine($"Топология: граней {d.FaceTypes.Count} ({GroupTypes(d.FaceTypes)}); " + + $"рёбер {d.EdgeTypes.Count} ({GroupTypes(d.EdgeTypes)})"); + + AppendTree(sb, d); + AppendVariables(sb, d.Variables); + + return sb.ToString().TrimEnd(); + } + + private static string FormatBodies(IReadOnlyList bodies) + { + if (bodies.Count == 0) return ""; + var parts = bodies.Select(b => $"тело #{b.Index}{(b.IsSolid ? " (твёрдое)" : "")}, граней {b.FaceCount}"); + return " — " + string.Join("; ", parts); + } + + private static void AppendTree(StringBuilder sb, ModelDescription d) + { + if (d.Imported) + { + var names = string.Join(", ", d.Features.Select(f => $"«{f.Name}»")); + sb.AppendLine("Дерево построения: импортированная модель без истории построения " + + "(доступна только топология)." + (names.Length > 0 ? " Узлы: " + names + "." : "")); + return; + } + + if (d.Features.Count == 0) + { + sb.AppendLine("Дерево построения: нет операций."); + return; + } + + sb.AppendLine($"Дерево построения ({d.Features.Count} операц.):"); + foreach (var f in d.Features) + sb.AppendLine($" [{f.Index}] «{f.Name}»" + (string.IsNullOrEmpty(f.Detail) ? "" : $" — {f.Detail}")); + } + + private static void AppendVariables(StringBuilder sb, IReadOnlyList vars) + { + if (vars.Count == 0) { sb.AppendLine("Переменные: нет"); return; } + + sb.AppendLine($"Переменные ({vars.Count}):"); + foreach (var v in vars) + { + var flags = (v.External ? " [внешняя]" : "") + (v.Information ? " [инфо]" : ""); + sb.AppendLine(Inv($" {v.Name} = {v.Expression} (= {v.Value:G}){flags}")); + } + } + + private static string Inv(FormattableString fs) => FormattableString.Invariant(fs); +} diff --git a/src/Kompas.Mcp.Core/Query/MeasureResult.cs b/src/Kompas.Mcp.Core/Query/MeasureResult.cs new file mode 100644 index 0000000..86476e0 --- /dev/null +++ b/src/Kompas.Mcp.Core/Query/MeasureResult.cs @@ -0,0 +1,17 @@ +namespace Kompas.Mcp.Core.Query; + +/// Результат измерения между двумя объектами модели (грань/ребро/вершина). +public sealed record MeasureResult +{ + /// Расстояние, мм (для совпадающих/пересекающихся объектов — 0). + public required double Distance { get; init; } + + /// Минимальное расстояние, мм. + public required double MinDistance { get; init; } + + /// Угол между объектами, градусы (валиден только когда ). + public required double Angle { get; init; } + + /// true — угол применим к данной паре объектов. + public required bool AngleValid { get; init; } +} diff --git a/src/Kompas.Mcp.Core/Query/ModelDescription.cs b/src/Kompas.Mcp.Core/Query/ModelDescription.cs new file mode 100644 index 0000000..16603a7 --- /dev/null +++ b/src/Kompas.Mcp.Core/Query/ModelDescription.cs @@ -0,0 +1,41 @@ +namespace Kompas.Mcp.Core.Query; + +/// +/// Структурный «паспорт» активной детали: всё, что нужно агенту, чтобы «увидеть» модель +/// без растрового снимка. Заполняется ModelInspectionService, рендерится InspectionText. +/// +public sealed record ModelDescription +{ + /// Имя документа/детали. + public required string Name { get; init; } + + /// Модель пуста (нет тел; габарит-«пустышка»). + public required bool IsEmpty { get; init; } + + /// Габарит детали (null для пустой модели). + public BoundingBox? Box { get; init; } + + /// Массово-центровочные характеристики (null для пустой модели). + public PartInfo? Mass { get; init; } + + /// Тела детали. + public required IReadOnlyList Bodies { get; init; } + + /// Типы всех граней (для сводки по топологии). + public required IReadOnlyList FaceTypes { get; init; } + + /// Типы всех рёбер (для сводки по топологии). + public required IReadOnlyList EdgeTypes { get; init; } + + /// Узлы дерева построения по порядку. + public required IReadOnlyList Features { get; init; } + + /// + /// Модель импортирована (дерево содержит только начало координат и тело(а), без + /// параметрической истории) — типично для импорта STEP. + /// + public required bool Imported { get; init; } + + /// Переменные модели. + public required IReadOnlyList Variables { get; init; } +} diff --git a/src/Kompas.Mcp.Core/Query/ModelInspectionService.cs b/src/Kompas.Mcp.Core/Query/ModelInspectionService.cs new file mode 100644 index 0000000..ddcc484 --- /dev/null +++ b/src/Kompas.Mcp.Core/Query/ModelInspectionService.cs @@ -0,0 +1,425 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Threading; +using Kompas6API5; +using Kompas6Constants3D; + +namespace Kompas.Mcp.Core.Query; + +/// +/// Структурное «зрение» агента: чтение модели как данных, а не картинки — дерево построения, +/// тела, топология, переменные, drill-down по граням/рёбрам и измерения. Всё через API5 +/// ksPart на STA-потоке . +/// +[SupportedOSPlatform("windows")] +public sealed class ModelInspectionService +{ + private const uint MixMm = 0x1; // длины/площади — мм + private const int MixMmKg = 0x1 | 0x10; // МЦХ — мм + кг + + private readonly KompasSession _session; + private readonly KompasDispatcher _dispatcher; + + public ModelInspectionService(KompasSession session, KompasDispatcher dispatcher) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + /// Собрать структурный «паспорт» активной детали одним проходом. + public Task DescribeModelAsync(CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var part = GetTopPart(); + try + { + var name = ActiveDocName(); + + bool hasGab = part.GetGabarit(false, false, + out double x1, out double y1, out double z1, out double x2, out double y2, out double z2); + var box = hasGab ? Box(x1, y1, z1, x2, y2, z2) : null; + bool empty = box is null || InspectionText.IsEmptyGabarit(box.SizeX, box.SizeY, box.SizeZ); + + if (empty) + return new ModelDescription + { + Name = name, IsEmpty = true, + Bodies = Array.Empty(), FaceTypes = Array.Empty(), + EdgeTypes = Array.Empty(), Features = Array.Empty(), + Imported = false, Variables = Array.Empty(), + }; + + var bodies = ReadBodies(part); + var (faceTypes, edgeTypes) = ReadTopology(part); + var features = ReadFeatures(part); + var variables = ReadVariables(part); + + return new ModelDescription + { + Name = name, + IsEmpty = false, + Box = box, + Mass = ReadMass(part), + Bodies = bodies, + FaceTypes = faceTypes, + EdgeTypes = edgeTypes, + Features = features, + Imported = InspectionText.IsImported(features, bodies.Count), + Variables = variables, + }; + } + finally { Release(part); } + }, ct); + + /// Перечислить узлы дерева построения по порядку. + public Task> ListFeaturesAsync(CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var part = GetTopPart(); + try { return ReadFeatures(part); } + finally { Release(part); } + }, ct); + + /// Перечислить тела детали. + public Task> ListBodiesAsync(CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var part = GetTopPart(); + try { return ReadBodies(part); } + finally { Release(part); } + }, ct); + + /// Перечислить переменные модели. + public Task> ListVariablesAsync(CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var part = GetTopPart(); + try { return ReadVariables(part); } + finally { Release(part); } + }, ct); + + /// Детали грани по индексу из list_faces: тип, площадь, нормаль, радиус, число рёбер. + public Task DescribeFaceAsync(int index, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var part = GetTopPart(); + try + { + var faces = Faces(part); + var def = FaceAt(faces, index); + + double area = 0; try { area = def.GetArea(MixMm); } catch { } + int edgeCount = 0; try { if (def.EdgeCollection() is ksEdgeCollection ec) edgeCount = ec.GetCount(); } catch { } + double? radius = null; + try { if (def.IsCylinder() && def.GetCylinderParam(out _, out double r)) radius = r; } catch { } + + return new FaceDetail + { + Index = index, + Type = FaceType(def), + Area = area, + Normal = FaceNormal(def), + Radius = radius, + EdgeCount = edgeCount, + }; + } + finally { Release(part); } + }, ct); + + /// Детали ребра по индексу из list_edges: тип, длина, смежные грани, концевые вершины. + public Task DescribeEdgeAsync(int index, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var part = GetTopPart(); + try + { + var edges = Edges(part); + var def = EdgeAt(edges, index); + + double len = 0; try { len = def.GetLength(MixMm); } catch { } + string? f1 = null, f2 = null; + try { if (def.GetAdjacentFace(true) is ksFaceDefinition a) f1 = FaceType(a); } catch { } + try { if (def.GetAdjacentFace(false) is ksFaceDefinition b) f2 = FaceType(b); } catch { } + + return new EdgeDetail + { + Index = index, + Type = EdgeType(def), + Length = len, + AdjacentFace1 = f1, + AdjacentFace2 = f2, + Start = Vertex(def, true), + End = Vertex(def, false), + }; + } + finally { Release(part); } + }, ct); + + /// + /// Измерить расстояние и угол между двумя объектами модели. / + /// — face|edge|vertex; индексы — из list_faces/list_edges. + /// + public Task MeasureAsync(string kind1, int index1, string kind2, int index2, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var part = GetTopPart(); + try + { + var o1 = ResolveObject(part, kind1, index1); + var o2 = ResolveObject(part, kind2, index2); + + var meas = part.GetMeasurer() as ksMeasurer + ?? throw new InvalidOperationException("GetMeasurer вернул null."); + meas.SetObject1(o1); + meas.SetObject2(o2); + meas.unit = MixMm; + if (!meas.Calc()) + throw new InvalidOperationException("Измерение не выполнено (Calc вернул FALSE)."); + + bool angleValid = false; try { angleValid = meas.IsAngleValid(); } catch { } + return new MeasureResult + { + Distance = meas.distance, + MinDistance = meas.MinDistance, + Angle = angleValid ? meas.angle : 0, + AngleValid = angleValid, + }; + } + finally { Release(part); } + }, ct); + + // ── чтение данных (на STA-потоке) ───────────────────────────────────── + + private static IReadOnlyList ReadFeatures(ksPart part) + { + // Дерево детали — через ksPart.GetFeature() (приведение part as ksFeature не срабатывает). + var root = part.GetFeature() as ksFeature ?? part as ksFeature; + if (root?.SubFeatureCollection(true, false) is not ksFeatureCollection tree) return Array.Empty(); + + int n = tree.GetCount(); + var list = new List(n); + for (int i = 0; i < n; i++) + { + if (tree.GetByIndex(i) is not ksFeature f) continue; + string name = ""; try { name = f.name ?? ""; } catch { } + var (kind, detail) = ClassifyFeature(f, name); + list.Add(new FeatureInfo { Index = i, Name = name, Kind = kind, Detail = detail }); + } + return list; + } + + // ksFeature.type отдаёт лишь coarse o3d_entity → точный вид и параметры берём из определения. + private static (string kind, string? detail) ClassifyFeature(ksFeature f, string name) + { + try + { + if (f.GetObject() is ksEntity ent) + switch (ent.GetDefinition()) + { + case ksBossExtrusionDefinition be: + return ("extrusion", be.GetSideParam(true, out _, out double d1, out _, out _) ? Inv($"глубина {d1:F2} мм") : null); + case ksCutExtrusionDefinition ce: + return ("cut_extrusion", ce.GetSideParam(true, out _, out double d2, out _, out _) ? Inv($"вырез, глубина {d2:F2} мм") : "вырез"); + case ksFilletDefinition fd: + return ("fillet", Inv($"R{fd.radius:F2} мм")); + case ksChamferDefinition cd: + return ("chamfer", cd.GetChamferParam(out _, out double s1, out double s2) ? Inv($"{s1:F2}×{s2:F2} мм") : null); + case ksBossRotatedDefinition: + return ("revolve", null); + case ksCutRotatedDefinition: + return ("cut_revolve", "вырез вращением"); + case ksSketchDefinition: + return ("sketch", null); + } + } + catch { /* параметры/тип best-effort */ } + + // Не распознанная операция: начало координат, импортированное тело либо иной узел-правка. + return name.StartsWith("Начало", StringComparison.OrdinalIgnoreCase) ? ("origin", null) : ("other", null); + } + + private static IReadOnlyList ReadBodies(ksPart part) + { + if (part.BodyCollection() is not ksBodyCollection bc) return Array.Empty(); + int n = bc.GetCount(); + var list = new List(n); + for (int i = 0; i < n; i++) + { + if (bc.GetByIndex(i) is not ksBody b) continue; + int faces = 0; try { if (b.FaceCollection() is ksFaceCollection fc) faces = fc.GetCount(); } catch { } + bool solid = false; try { solid = b.IsSolid(); } catch { } + list.Add(new BodyInfo { Index = i, IsSolid = solid, FaceCount = faces }); + } + return list; + } + + private static IReadOnlyList ReadVariables(ksPart part) + { + if (part.VariableCollection() is not ksVariableCollection vc) return Array.Empty(); + int n = vc.GetCount(); + var list = new List(n); + for (int i = 0; i < n; i++) + { + if (vc.GetByIndex(i) is not ksVariable v) continue; + list.Add(new VariableInfo + { + Name = SafeStr(() => v.name), + Expression = SafeStr(() => v.Expression), + Value = SafeNum(() => v.value), + External = SafeBool(() => v.external), + Information = SafeBool(() => v.Information), + }); + } + return list; + } + + private static (List faceTypes, List edgeTypes) ReadTopology(ksPart part) + { + var faceTypes = new List(); + var edgeTypes = new List(); + if (Faces(part) is { } faces) + for (int i = 0; i < faces.GetCount(); i++) + if (faces.GetByIndex(i) is ksEntity e && e.GetDefinition() is ksFaceDefinition d) faceTypes.Add(FaceType(d)); + if (Edges(part) is { } edges) + for (int i = 0; i < edges.GetCount(); i++) + if (edges.GetByIndex(i) is ksEntity e && e.GetDefinition() is ksEdgeDefinition d) edgeTypes.Add(EdgeType(d)); + return (faceTypes, edgeTypes); + } + + private static PartInfo? ReadMass(ksPart part) + { + try + { + if (part.CalcMassInertiaProperties(MixMmKg) is not ksMassInertiaParam m) return null; + return new PartInfo + { + Volume = m.v, Mass = m.m, Area = m.F, + CenterX = m.xc, CenterY = m.yc, CenterZ = m.zc, + }; + } + catch { return null; } + } + + private static (double X, double Y, double Z)? FaceNormal(ksFaceDefinition def) + { + try + { + if (def.GetSurface() is not ksSurface surf) return null; + double u = (surf.GetParamUMin() + surf.GetParamUMax()) / 2; + double v = (surf.GetParamVMin() + surf.GetParamVMax()) / 2; + if (!surf.GetNormal(u, v, out double nx, out double ny, out double nz)) return null; + if (!def.normalOrientation) { nx = -nx; ny = -ny; nz = -nz; } + return (nx, ny, nz); + } + catch { return null; } + } + + private static (double X, double Y, double Z)? Vertex(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 { } + return null; + } + + private object ResolveObject(ksPart part, string kind, int index) + { + var type = kind.Trim().ToLowerInvariant() switch + { + "face" => Obj3dType.o3d_face, + "edge" => Obj3dType.o3d_edge, + "vertex" => Obj3dType.o3d_vertex, + _ => throw new ArgumentException($"Неизвестный тип объекта '{kind}'. Допустимо: face, edge, vertex."), + }; + var coll = part.EntityCollection((short)type) as ksEntityCollection + ?? throw new InvalidOperationException($"Не удалось получить коллекцию {kind}."); + if (index < 0 || index >= coll.GetCount()) + throw new ArgumentOutOfRangeException(nameof(index), $"Индекс {kind} вне диапазона [0; {coll.GetCount() - 1}]."); + return coll.GetByIndex(index) as ksEntity + ?? throw new InvalidOperationException($"Объект {kind}[{index}] не приводится к ksEntity."); + } + + // ── низкоуровневые помощники ────────────────────────────────────────── + + private ksPart GetTopPart() + { + var doc3d = _session.Kompas.ActiveDocument3D() as ksDocument3D + ?? throw new InvalidOperationException("Нет активного 3D-документа."); + return doc3d.GetPart((short)Part_Type.pTop_Part) as ksPart + ?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart)."); + } + + private string ActiveDocName() + { + var doc = _session.Application.ActiveDocument; + try { return doc?.Name ?? ""; } + catch { return ""; } + finally { Release(doc); } + } + + private static ksEntityCollection? Faces(ksPart part) => part.EntityCollection((short)Obj3dType.o3d_face) as ksEntityCollection; + private static ksEntityCollection? Edges(ksPart part) => part.EntityCollection((short)Obj3dType.o3d_edge) as ksEntityCollection; + + private static ksFaceDefinition FaceAt(ksEntityCollection? faces, int index) + { + if (faces is null) throw new InvalidOperationException("Не удалось получить коллекцию граней."); + if (index < 0 || index >= faces.GetCount()) + throw new ArgumentOutOfRangeException(nameof(index), $"Индекс грани вне диапазона [0; {faces.GetCount() - 1}]. Сверьтесь с list_faces."); + return (faces.GetByIndex(index) as ksEntity)?.GetDefinition() as ksFaceDefinition + ?? throw new InvalidOperationException($"Грань[{index}] не приводится к ksFaceDefinition."); + } + + private static ksEdgeDefinition EdgeAt(ksEntityCollection? edges, int index) + { + if (edges is null) throw new InvalidOperationException("Не удалось получить коллекцию рёбер."); + if (index < 0 || index >= edges.GetCount()) + throw new ArgumentOutOfRangeException(nameof(index), $"Индекс ребра вне диапазона [0; {edges.GetCount() - 1}]. Сверьтесь с list_edges."); + return (edges.GetByIndex(index) as ksEntity)?.GetDefinition() as ksEdgeDefinition + ?? throw new InvalidOperationException($"Ребро[{index}] не приводится к ksEdgeDefinition."); + } + + private static string FaceType(ksFaceDefinition d) + { + try { if (d.IsPlanar()) return "plane"; } catch { } + try { if (d.IsCylinder()) return "cylinder"; } catch { } + try { if (d.IsCone()) return "cone"; } catch { } + try { if (d.IsSphere()) return "sphere"; } catch { } + try { if (d.IsTorus()) return "torus"; } catch { } + try { if (d.IsNurbsSurface()) return "nurbs"; } catch { } + return "other"; + } + + private static string EdgeType(ksEdgeDefinition d) + { + try { if (d.IsLineSeg()) return "line"; } catch { } + try { if (d.IsCircle()) return "circle"; } catch { } + try { if (d.IsArc()) return "arc"; } catch { } + try { if (d.IsEllipseArc()) return "ellipse_arc"; } catch { } + try { if (d.IsEllipse()) return "ellipse"; } catch { } + try { if (d.IsNurbs()) return "nurbs"; } catch { } + return "other"; + } + + private static BoundingBox Box(double x1, double y1, double z1, double x2, double y2, double z2) => new() + { + MinX = Math.Min(x1, x2), MinY = Math.Min(y1, y2), MinZ = Math.Min(z1, z2), + MaxX = Math.Max(x1, x2), MaxY = Math.Max(y1, y2), MaxZ = Math.Max(z1, z2), + }; + + private static string SafeStr(Func g) { try { return g() ?? ""; } catch { return ""; } } + private static double SafeNum(Func g) { try { return g(); } catch { return 0; } } + private static bool SafeBool(Func g) { try { return g(); } catch { return false; } } + private static string Inv(FormattableString fs) => FormattableString.Invariant(fs); + + private static void Release(object? com) + { + if (com is not null && Marshal.IsComObject(com)) + { + try { Marshal.ReleaseComObject(com); } catch { } + } + } +} diff --git a/src/Kompas.Mcp.Core/Query/QueryService.cs b/src/Kompas.Mcp.Core/Query/QueryService.cs index b455c62..b575770 100644 --- a/src/Kompas.Mcp.Core/Query/QueryService.cs +++ b/src/Kompas.Mcp.Core/Query/QueryService.cs @@ -61,6 +61,53 @@ public sealed class QueryService } }, ct); + /// + /// Перечислить компоненты верхнего уровня активной сборки (детали/подсборки): имя, + /// обозначение, файл, признак детали и габарит. Обход через API7 TopPart → Parts. + /// + public Task> ListComponentsAsync(CancellationToken ct = default) + => _dispatcher.InvokeAsync>(() => + { + if (_session.Application.ActiveDocument is not KompasAPI7.IKompasDocument3D doc3d) + throw new InvalidOperationException("Активный документ не является 3D-документом."); + if (doc3d.TopPart is not KompasAPI7.IPart7 top) + throw new InvalidOperationException("Не удалось получить TopPart активного документа."); + + var parts = top.Parts; + int count = parts.Count; + var list = new List(count); + for (int i = 0; i < count; i++) + { + if (parts.Part[i] is not KompasAPI7.IPart7 p) continue; + double x1 = 0, y1 = 0, z1 = 0, x2 = 0, y2 = 0, z2 = 0; + bool gab = false; + try { gab = p.GetGabarit(false, false, out x1, out y1, out z1, out x2, out y2, out z2); } + catch { /* габарит может быть недоступен у отдельных компонентов */ } + list.Add(new ComponentInfo + { + Index = i, + Name = SafeGet(() => p.Name), + Marking = SafeGet(() => p.Marking), + FileName = SafeGet(() => p.FileName), + IsDetail = SafeBool(() => p.Detail), + SizeX = gab ? Math.Abs(x2 - x1) : 0, + SizeY = gab ? Math.Abs(y2 - y1) : 0, + SizeZ = gab ? Math.Abs(z2 - z1) : 0, + }); + } + return list; + }, ct); + + private static string SafeGet(Func get) + { + try { return get() ?? string.Empty; } catch { return string.Empty; } + } + + private static bool SafeBool(Func get) + { + try { return get(); } catch { return false; } + } + // ST_MIX_MM (0x1) — площади граней в мм². private const uint MixMm = 0x1; diff --git a/src/Kompas.Mcp.Core/Query/VariableInfo.cs b/src/Kompas.Mcp.Core/Query/VariableInfo.cs new file mode 100644 index 0000000..6a597a0 --- /dev/null +++ b/src/Kompas.Mcp.Core/Query/VariableInfo.cs @@ -0,0 +1,19 @@ +namespace Kompas.Mcp.Core.Query; + +/// Переменная модели (имя, выражение, значение, признаки). +public sealed record VariableInfo +{ + public required string Name { get; init; } + + /// Выражение переменной (может быть числом или формулой со ссылками на другие переменные). + public required string Expression { get; init; } + + /// Вычисленное числовое значение. + public required double Value { get; init; } + + /// Внешняя переменная (доступна из вышестоящих документов/исполнений). + public required bool External { get; init; } + + /// Информационная переменная (вычисляется, не задаёт геометрию). + public required bool Information { get; init; } +} diff --git a/src/Kompas.Mcp.Core/Validation/Errors3d.cs b/src/Kompas.Mcp.Core/Validation/Errors3d.cs new file mode 100644 index 0000000..da773a5 --- /dev/null +++ b/src/Kompas.Mcp.Core/Validation/Errors3d.cs @@ -0,0 +1,31 @@ +namespace Kompas.Mcp.Core.Validation; + +/// +/// Расшифровка кодов ошибок построения КОМПАС (ErrorType3d), которые возвращает +/// IFeature7.ObjectError. 0 — ошибки нет. +/// +public static class Errors3d +{ + /// true, если код означает отсутствие ошибки. + public static bool IsOk(int code) => code == 0; + + /// Человекочитаемое описание кода ошибки построения. + public static string Describe(int code) => code switch + { + 0 => "ошибки нет", + -7 => "документ не активизирован или не является деталью/сборкой", + -1 => "аварийное завершение операции", + 54 => "неизвестная ошибка построения (операция не выполнена)", + 123 => "объекты пересекаются", + 130 => "ошибочная топология", + 135 => "не найдены одна или несколько исходных операций", + 144 => "невозможная операция", + 145 => "операция потеряла опорные объекты", + 179 => "контур невозможно использовать для заданного перемещения", + 185 => "объект не найден", + 193 => "доступ к объекту запрещён", + 199 => "параметр операции был изменён", + 202 => "ошибка при создании скругления грани", + _ => $"ошибка построения #{code}", + }; +} diff --git a/src/Kompas.Mcp.Core/Validation/OperationError.cs b/src/Kompas.Mcp.Core/Validation/OperationError.cs new file mode 100644 index 0000000..72f4f3f --- /dev/null +++ b/src/Kompas.Mcp.Core/Validation/OperationError.cs @@ -0,0 +1,20 @@ +namespace Kompas.Mcp.Core.Validation; + +/// Диагностика операции дерева построения, находящейся в ошибке/требующей перестроения. +public sealed record OperationError +{ + /// Имя операции в дереве. + public required string Name { get; init; } + + /// Код ошибки построения (ErrorType3d); 0 — ошибки нет. + public required int ErrorCode { get; init; } + + /// Человекочитаемое описание кода. + public required string Description { get; init; } + + /// false — операция вырождена (не дала корректной геометрии). + public required bool Valid { get; init; } + + /// true — операция помечена «требует перестроения» (флаг ksFSRebuild). + public required bool NeedsRebuild { get; init; } +} diff --git a/src/Kompas.Mcp.Core/Validation/ValidationService.cs b/src/Kompas.Mcp.Core/Validation/ValidationService.cs new file mode 100644 index 0000000..9f3fb01 --- /dev/null +++ b/src/Kompas.Mcp.Core/Validation/ValidationService.cs @@ -0,0 +1,82 @@ +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Threading; +using Kompas6Constants; +using KompasAPI7; + +namespace Kompas.Mcp.Core.Validation; + +/// +/// Валидация построения активной детали/сборки: какие операции в ошибке или требуют перестроения. +/// Читает IFeature7.Valid / ObjectError / State по дереву (SubFeatures). +/// +[SupportedOSPlatform("windows")] +public sealed class ValidationService +{ + private const int KsFsRebuild = 0x2; // ksFeatureStateEnum.ksFSRebuild — «требует перестроения» + + private readonly KompasSession _session; + private readonly KompasDispatcher _dispatcher; + + public ValidationService(KompasSession session, KompasDispatcher dispatcher) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + /// + /// Проверить активную деталь/сборку: вернуть список операций, которые вырождены (Valid=false), + /// имеют код ошибки (ObjectError≠0) или помечены «требует перестроения». Пустой список — построение чистое. + /// + public Task> ValidatePartAsync(CancellationToken ct = default) + => _dispatcher.InvokeAsync>(() => + { + if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d) + throw new InvalidOperationException("Активный документ не является 3D-документом."); + if (doc3d.TopPart is not IFeature7 top) + throw new InvalidOperationException("Не удалось получить TopPart как IFeature7."); + + var result = new List(); + CheckFeature(top, result); + foreach (var f in EnumerateSubFeatures(top)) + CheckFeature(f, result); + return result; + }, ct); + + private static IEnumerable EnumerateSubFeatures(IFeature7 root) + { + object? subs = null; + try { subs = root.SubFeatures[ksTreeTypeEnum.ksOperTree, true, false]; } + catch { yield break; } + + if (subs is object[] arr) + { + foreach (var o in arr) if (o is IFeature7 f) yield return f; + } + else if (subs is System.Collections.IEnumerable en) + { + foreach (var o in en) if (o is IFeature7 f) yield return f; + } + } + + private static void CheckFeature(IFeature7 f, List acc) + { + bool valid = true; + int err = 0, state = 0; + try { valid = f.Valid; } catch { /* ignore */ } + try { err = f.ObjectError; } catch { /* ignore */ } + try { state = f.State; } catch { /* ignore */ } + + bool needsRebuild = (state & KsFsRebuild) != 0; + if (valid && Errors3d.IsOk(err) && !needsRebuild) return; + + string name; try { name = f.Name ?? string.Empty; } catch { name = string.Empty; } + acc.Add(new OperationError + { + Name = name, + ErrorCode = err, + Description = Errors3d.Describe(err), + Valid = valid, + NeedsRebuild = needsRebuild, + }); + } +} diff --git a/src/Kompas.Mcp.Host/Program.cs b/src/Kompas.Mcp.Host/Program.cs index cd297da..c5fbbe3 100644 --- a/src/Kompas.Mcp.Host/Program.cs +++ b/src/Kompas.Mcp.Host/Program.cs @@ -1,8 +1,11 @@ using Kompas.Mcp.Core; +using Kompas.Mcp.Core.Conversion; using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Editing; using Kompas.Mcp.Core.Modeling; using Kompas.Mcp.Core.Query; using Kompas.Mcp.Core.Threading; +using Kompas.Mcp.Core.Validation; using Kompas.Mcp.Core.Vision; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -21,6 +24,11 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // MCP-сервер поверх stdio; инструменты находятся по атрибутам в этой сборке. builder.Services diff --git a/src/Kompas.Mcp.Host/Tools/ConversionTools.cs b/src/Kompas.Mcp.Host/Tools/ConversionTools.cs new file mode 100644 index 0000000..e6382cc --- /dev/null +++ b/src/Kompas.Mcp.Host/Tools/ConversionTools.cs @@ -0,0 +1,41 @@ +using System.ComponentModel; +using Kompas.Mcp.Core; +using Kompas.Mcp.Core.Conversion; +using Kompas.Mcp.Core.Documents; +using ModelContextProtocol.Server; + +namespace Kompas.Mcp.Host.Tools; + +/// Инструменты обмена форматами: импорт/экспорт STEP. +[McpServerToolType] +public sealed class ConversionTools(KompasSession session, ConversionService conversion) +{ + [McpServerTool(Name = "import_step")] + [Description("Импортировать STEP-файл (.step/.stp) в новый документ и сделать его активным. " + + "type: assembly (по умолчанию) | part — тип целевого документа. " + + "createComponentFiles=true записывает компоненты сборки отдельными .m3d рядом с исходным STEP " + + "(нужно, чтобы потом открыть/редактировать отдельную деталь). Возвращает тип созданного документа.")] + public async Task ImportStep( + [Description("Полный путь к .step/.stp")] string path, + [Description("Тип документа: assembly | part")] string type = "assembly", + [Description("Создавать файлы компонентов сборки рядом со STEP")] bool createComponentFiles = false) + { + await session.ConnectAsync(); + var docType = DocumentTypes.Parse(type); + await conversion.ImportStepAsync(path, docType, createComponentFiles); + return $"STEP импортирован в документ типа {docType}: {path}"; + } + + [McpServerTool(Name = "export_step")] + [Description("Экспортировать активный 3D-документ (деталь/сборку) в STEP по указанному пути. " + + "format: auto (по умолчанию) | ap203 | ap214 | ap242.")] + public async Task ExportStep( + [Description("Полный путь к выходному .step")] string path, + [Description("Стандарт STEP: auto | ap203 | ap214 | ap242")] string format = "auto") + { + await session.ConnectAsync(); + var fmt = StepFormats.Parse(format); + await conversion.ExportStepAsync(path, fmt); + return $"Экспортировано в STEP ({fmt}): {path}"; + } +} diff --git a/src/Kompas.Mcp.Host/Tools/EditTools.cs b/src/Kompas.Mcp.Host/Tools/EditTools.cs new file mode 100644 index 0000000..53e5d73 --- /dev/null +++ b/src/Kompas.Mcp.Host/Tools/EditTools.cs @@ -0,0 +1,78 @@ +using System.ComponentModel; +using Kompas.Mcp.Core; +using Kompas.Mcp.Core.Editing; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Validation; +using ModelContextProtocol.Server; + +namespace Kompas.Mcp.Host.Tools; + +/// Инструменты прямого редактирования тела (без дерева построения). +[McpServerToolType] +public sealed class EditTools( + KompasSession session, + FaceEditService faceEdit, + SolidEditService solidEdit, + ValidationService validation) +{ + [McpServerTool(Name = "move_face")] + [Description("Прямое редактирование («Переместить грань»): сдвинуть грань детали, найденную по мировой точке " + + "(x,y,z в мм, точка должна лежать на грани), на distance мм вдоль её нормали. distance>0 — наружу " + + "(добавить материал, удлинить деталь), distance<0 — внутрь. Направление нормали подбирается автоматически. " + + "Работает и на импортированной (STEP) геометрии без дерева построения. Перед вызовом сделайте " + + "model_snapshot/list_faces и прикиньте точку на нужной грани.")] + public async Task MoveFace(double x, double y, double z, double distance) + { + await session.ConnectAsync(); + await faceEdit.MoveFaceAsync(x, y, z, distance); + return await WithValidation($"Грань в точке ({x}, {y}, {z}) перемещена на {distance} мм вдоль нормали."); + } + + [McpServerTool(Name = "split_solid_by_plane")] + [Description("Рассечь тело активной детали смещённой плоскостью: база plane (XOY|XOZ|YOZ) + offset мм. " + + "Получаются отдельные тела — возвращает их список с индексом и габаритом по осям, чтобы выбрать " + + "тело для move_body. Шаг 1 метода «рассечь→раздвинуть→объединить».")] + public async Task SplitSolidByPlane( + [Description("Базовая плоскость: XOY | XOZ | YOZ")] string plane, + [Description("Смещение плоскости от базовой, мм")] double offset, + [Description("Прямое направление отступа")] bool direction = true) + { + await session.ConnectAsync(); + var bodies = await solidEdit.SplitByPlaneAsync(BasePlanes.Parse(plane), offset, direction); + var lines = bodies.Select(b => + $" [{b.Index}] габ {b.SizeX:F1}×{b.SizeY:F1}×{b.SizeZ:F1} мм, " + + $"Z[{b.MinZ:F1}; {b.MaxZ:F1}]"); + return $"Рассечение {plane}+{offset}: тел стало {bodies.Count}\n" + string.Join("\n", lines); + } + + [McpServerTool(Name = "move_body")] + [Description("Переместить тело (по индексу из split_solid_by_plane) на вектор (dx, dy, dz) мм. " + + "Шаг 2 метода «рассечь→раздвинуть→объединить» — раздвигает части на нужный зазор.")] + public async Task MoveBody(int bodyIndex, double dx = 0, double dy = 0, double dz = 0) + { + await session.ConnectAsync(); + await solidEdit.MoveBodyAsync(bodyIndex, dx, dy, dz); + return await WithValidation($"Тело [{bodyIndex}] перемещено на ({dx}, {dy}, {dz}) мм."); + } + + [McpServerTool(Name = "boolean_union")] + [Description("Объединить все тела активной детали в одно (булева операция «объединение»). " + + "Шаг 4 (финал) метода «рассечь→раздвинуть→объединить»: после стыковки граней (move_face) " + + "сливает части в единое тело.")] + public async Task BooleanUnion() + { + await session.ConnectAsync(); + await solidEdit.UnionAllAsync(); + return await WithValidation("Тела объединены."); + } + + /// Дописать к сообщению результат проверки построения (операция могла «пройти», оставив ошибку). + private async Task 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) + + ". Результат может быть некорректным."; + } +} diff --git a/src/Kompas.Mcp.Host/Tools/InspectionTools.cs b/src/Kompas.Mcp.Host/Tools/InspectionTools.cs new file mode 100644 index 0000000..2c4761e --- /dev/null +++ b/src/Kompas.Mcp.Host/Tools/InspectionTools.cs @@ -0,0 +1,109 @@ +using System.ComponentModel; +using Kompas.Mcp.Core; +using Kompas.Mcp.Core.Query; +using ModelContextProtocol.Server; + +namespace Kompas.Mcp.Host.Tools; + +/// +/// Структурное «зрение» агента — чтение модели как данных, а не картинки. +/// Предпочтительнее снимка (model_snapshot): точнее и дешевле по контексту. +/// +[McpServerToolType] +public sealed class InspectionTools(KompasSession session, ModelInspectionService inspection) +{ + [McpServerTool(Name = "describe_model")] + [Description("ПРЕДПОЧТИТЕЛЬНЫЙ способ «увидеть» активную деталь — структурный паспорт одним вызовом: " + + "габарит, МЦХ, тела, сводка топологии (грани/рёбра по типам), дерево построения с параметрами и переменные. " + + "Вызывайте ПЕРЕД model_snapshot: это точнее снимка и дешевле по контексту. Снимок берите только для " + + "визуально-пространственных вопросов, на которые паспорт не отвечает.")] + public async Task DescribeModel() + { + await session.ConnectAsync(); + var d = await inspection.DescribeModelAsync(); + return InspectionText.Render(d); + } + + [McpServerTool(Name = "list_features")] + [Description("Дерево построения активной детали по порядку: индекс, имя операции и ключевые параметры " + + "(глубина/радиус/катеты). Для импортированных моделей (STEP) истории нет — только начало координат и тело.")] + public async Task ListFeatures() + { + await session.ConnectAsync(); + var fs = await inspection.ListFeaturesAsync(); + if (fs.Count == 0) return "Дерево построения пусто."; + var lines = fs.Select(f => $" [{f.Index}] «{f.Name}»" + (string.IsNullOrEmpty(f.Detail) ? "" : $" — {f.Detail}")); + return $"Операций: {fs.Count}\n" + string.Join("\n", lines); + } + + [McpServerTool(Name = "list_bodies")] + [Description("Тела активной детали: индекс, признак твёрдого тела и число граней. Полезно для многотельных деталей.")] + public async Task ListBodies() + { + await session.ConnectAsync(); + var bs = await inspection.ListBodiesAsync(); + if (bs.Count == 0) return "Тел нет (модель пуста?)."; + var lines = bs.Select(b => $" [{b.Index}] {(b.IsSolid ? "твёрдое" : "поверхность")}, граней {b.FaceCount}"); + return $"Тел: {bs.Count}\n" + string.Join("\n", lines); + } + + [McpServerTool(Name = "list_variables")] + [Description("Переменные активной детали: имя, выражение, значение и признаки (внешняя/информационная). " + + "Это «ручки управления» параметрической модели.")] + public async Task ListVariables() + { + await session.ConnectAsync(); + var vs = await inspection.ListVariablesAsync(); + if (vs.Count == 0) return "Переменных нет."; + var lines = vs.Select(v => + { + var flags = (v.External ? " [внешняя]" : "") + (v.Information ? " [инфо]" : ""); + return Inv($" {v.Name} = {v.Expression} (= {v.Value:G}){flags}"); + }); + return $"Переменных: {vs.Count}\n" + string.Join("\n", lines); + } + + [McpServerTool(Name = "describe_face")] + [Description("Детали грани по индексу из list_faces: тип, площадь, нормаль в центре, радиус (для цилиндра) " + + "и число ограничивающих рёбер. Углубляет list_faces без угадывания координат.")] + public async Task 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 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. Возвращает минимальное расстояние (мм) и угол (°, если применим).")] + public async Task Measure( + [Description("Тип первого объекта: face|edge|vertex")] string kind1, + [Description("Индекс первого объекта")] int index1, + [Description("Тип второго объекта: face|edge|vertex")] string kind2, + [Description("Индекс второго объекта")] int index2) + { + await session.ConnectAsync(); + var m = await inspection.MeasureAsync(kind1, index1, kind2, index2); + var angle = m.AngleValid ? Inv($", угол {m.Angle:F2}°") : " (угол неприменим)"; + return Inv($"{kind1}[{index1}] ↔ {kind2}[{index2}]: расстояние {m.MinDistance:F2} мм{angle}"); + } + + private static string Inv(FormattableString fs) => FormattableString.Invariant(fs); +} diff --git a/src/Kompas.Mcp.Host/Tools/QueryTools.cs b/src/Kompas.Mcp.Host/Tools/QueryTools.cs index bfb0146..743adab 100644 --- a/src/Kompas.Mcp.Host/Tools/QueryTools.cs +++ b/src/Kompas.Mcp.Host/Tools/QueryTools.cs @@ -19,6 +19,22 @@ public sealed class QueryTools(KompasSession session, QueryService query) $"центр масс: ({i.CenterX:F2}, {i.CenterY:F2}, {i.CenterZ:F2})."; } + [McpServerTool(Name = "list_components")] + [Description("Перечислить компоненты верхнего уровня активной сборки: индекс, имя, обозначение, признак детали и габарит (мм). Индекс/имя — для последующего извлечения или осмотра детали.")] + public async Task ListComponents() + { + await session.ConnectAsync(); + var comps = await query.ListComponentsAsync(); + if (comps.Count == 0) return "Компонентов нет (активный документ не сборка или пуст)."; + var lines = comps.Select(c => + { + var mark = string.IsNullOrWhiteSpace(c.Marking) ? "" : $", обозн. \"{c.Marking}\""; + return $" [{c.Index}] {(c.IsDetail ? "деталь" : "узел ")} \"{c.Name}\", " + + $"габ {c.SizeX:F1}×{c.SizeY:F1}×{c.SizeZ:F1} мм{mark}"; + }); + return $"Компонентов: {comps.Count}\n" + string.Join("\n", lines); + } + [McpServerTool(Name = "list_faces")] [Description("Перечислить грани активной детали: индекс, тип поверхности (plane/cylinder/cone/sphere/torus/nurbs) и площадь (мм²). Индекс используйте в sketch_create_on_face_index. Удобно для надёжного выбора грани без угадывания координат.")] public async Task ListFaces() diff --git a/src/Kompas.Mcp.Host/Tools/ValidationTools.cs b/src/Kompas.Mcp.Host/Tools/ValidationTools.cs new file mode 100644 index 0000000..ffd4bf3 --- /dev/null +++ b/src/Kompas.Mcp.Host/Tools/ValidationTools.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; +using Kompas.Mcp.Core; +using Kompas.Mcp.Core.Validation; +using ModelContextProtocol.Server; + +namespace Kompas.Mcp.Host.Tools; + +/// Инструменты валидации построения. +[McpServerToolType] +public sealed class ValidationTools(KompasSession session, ValidationService validation) +{ + [McpServerTool(Name = "validate_part")] + [Description("Проверить построение активной детали/сборки: вернуть операции, которые в ошибке (вырождены), " + + "имеют код ошибки ErrorType3d или помечены «требует перестроения» (красный «!» в дереве). " + + "Пустой результат — построение чистое. Вызывай после операций редактирования (move_face, extrude и т.п.).")] + public async Task ValidatePart() + { + await session.ConnectAsync(); + var errs = await validation.ValidatePartAsync(); + if (errs.Count == 0) return "Построение чистое: операций в ошибке нет."; + var lines = errs.Select(e => + { + var flags = (e.Valid ? "" : "вырождена; ") + (e.NeedsRebuild ? "требует перестроения; " : ""); + return $" • \"{e.Name}\": {flags}код {e.ErrorCode} — {e.Description}"; + }); + return $"Проблемных операций: {errs.Count}\n" + string.Join("\n", lines); + } +} diff --git a/src/Kompas.Mcp.Host/Tools/VisionTools.cs b/src/Kompas.Mcp.Host/Tools/VisionTools.cs index 7cc8f03..3d66c3e 100644 --- a/src/Kompas.Mcp.Host/Tools/VisionTools.cs +++ b/src/Kompas.Mcp.Host/Tools/VisionTools.cs @@ -11,7 +11,9 @@ namespace Kompas.Mcp.Host.Tools; public sealed class VisionTools(KompasSession session, SnapshotService snapshot) { [McpServerTool(Name = "model_snapshot")] - [Description("Снять текущий вид активной 3D-модели в PNG и вернуть как изображение — чтобы увидеть результат построения и решить следующий шаг.")] + [Description("Растровый снимок активной 3D-модели в PNG — FALLBACK. Сначала используйте describe_model (структурный паспорт: " + + "дерево, топология, габарит, МЦХ) — он точнее и дешевле. Снимок берите только для визуально-пространственных " + + "вопросов, на которые структурные запросы не отвечают: взаимное расположение, ориентация, общая форма.")] public async Task Snapshot( [Description("Разрешение в DPI; 0 — экранное")] int resolution = 0) { diff --git a/tests/Kompas.Mcp.Tests/Error3dTests.cs b/tests/Kompas.Mcp.Tests/Error3dTests.cs new file mode 100644 index 0000000..ada963e --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Error3dTests.cs @@ -0,0 +1,25 @@ +using Kompas.Mcp.Core.Validation; + +namespace Kompas.Mcp.Tests; + +/// Юнит-тесты расшифровки кодов ошибок построения (ErrorType3d). COM не требуется. +[Trait("Category", "Unit")] +public sealed class Error3dTests +{ + [Theory] + [InlineData(123, "пересек")] + [InlineData(130, "топологи")] + [InlineData(144, "невозможн")] + [InlineData(179, "контур")] + [InlineData(185, "не найден")] + public void Describe_contains_meaningful_text(int code, string fragment) + => Assert.Contains(fragment, Errors3d.Describe(code), StringComparison.OrdinalIgnoreCase); + + [Fact] + public void Unknown_code_falls_back_to_number() + => Assert.Contains("777", Errors3d.Describe(777)); + + [Fact] + public void Zero_code_means_no_error() + => Assert.True(Errors3d.IsOk(0) && !Errors3d.IsOk(144)); +} diff --git a/tests/Kompas.Mcp.Tests/InspectionTextTests.cs b/tests/Kompas.Mcp.Tests/InspectionTextTests.cs new file mode 100644 index 0000000..3d792d4 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/InspectionTextTests.cs @@ -0,0 +1,178 @@ +using Kompas.Mcp.Core.Query; + +namespace Kompas.Mcp.Tests; + +/// Юнит-тесты чистого рендера/группировки структурного «паспорта». COM не требуется. +[Trait("Category", "Unit")] +public sealed class InspectionTextTests +{ + // ── Детект пустой модели по габариту ────────────────────────────────── + [Fact] + public void IsEmptyGabarit_true_for_sentinel() + => Assert.True(InspectionText.IsEmptyGabarit(double.MaxValue, double.MaxValue, double.MaxValue)); + + [Fact] + public void IsEmptyGabarit_true_for_all_zero() + => Assert.True(InspectionText.IsEmptyGabarit(0, 0, 0)); + + [Fact] + public void IsEmptyGabarit_false_for_real_part() + => Assert.False(InspectionText.IsEmptyGabarit(40, 20, 15)); + + // ── Группировка типов ──────────────────────────────────────────────── + [Fact] + public void GroupTypes_orders_by_count_desc() + { + var s = InspectionText.GroupTypes(new[] { "plane", "cylinder", "plane", "plane", "cylinder" }); + Assert.Equal("3 plane, 2 cylinder", s); + } + + [Fact] + public void GroupTypes_empty_returns_dash() + => Assert.Equal("—", InspectionText.GroupTypes(Array.Empty())); + + // ── Детект импорта без истории ─────────────────────────────────────── + private static FeatureInfo Feat(int i, string name, string kind) => new() { Index = i, Name = name, Kind = kind }; + + [Fact] + public void IsImported_true_for_bare_origin_and_body() + { + var fs = new[] { Feat(0, "Начало координат", "origin"), Feat(1, "Body1", "other") }; + Assert.True(InspectionText.IsImported(fs, bodyCount: 1)); + } + + [Fact] + public void IsImported_false_when_tree_has_edit_history() + { + // Импорт, отредактированный move_face: origin + тело + 5 узлов-правок (>2) — это уже история. + var fs = new[] + { + Feat(0, "Начало координат", "origin"), Feat(1, "Body1", "other"), + Feat(2, "Смещенная плоскость:1", "other"), Feat(3, "Разрезать:1", "other"), + Feat(4, "Переместить грани:1", "other"), Feat(5, "Булева операция:1", "other"), + }; + Assert.False(InspectionText.IsImported(fs, bodyCount: 1)); + } + + [Fact] + public void IsImported_false_for_native_part_with_operations() + { + var fs = new[] + { + Feat(0, "Начало координат", "origin"), Feat(1, "Эскиз:1", "sketch"), + Feat(2, "Элемент выдавливания:1", "extrusion"), Feat(3, "Скругление:1", "fillet"), + }; + Assert.False(InspectionText.IsImported(fs, bodyCount: 1)); + } + + [Fact] + public void IsImported_false_without_body() + => Assert.False(InspectionText.IsImported(new[] { Feat(0, "Начало координат", "origin") }, bodyCount: 0)); + + // ── Рендер: родная параметрическая деталь ──────────────────────────── + [Fact] + public void Render_native_part_shows_tree_with_params() + { + var d = new ModelDescription + { + Name = "demo", + IsEmpty = false, + Box = new BoundingBox { MinX = -20, MinY = -10, MinZ = 0, MaxX = 20, MaxY = 10, MaxZ = 15 }, + Mass = new PartInfo { Volume = 11961, Mass = 0.094, Area = 3370, CenterX = 0, CenterY = 0, CenterZ = 7.5 }, + Bodies = new[] { new BodyInfo { Index = 0, IsSolid = true, FaceCount = 7 } }, + FaceTypes = new[] { "plane", "plane", "cylinder" }, + EdgeTypes = new[] { "line", "line" }, + Imported = false, + Features = new[] + { + new FeatureInfo { Index = 0, Name = "Начало координат", Kind = "origin" }, + new FeatureInfo { Index = 1, Name = "Эскиз:1", Kind = "sketch" }, + new FeatureInfo { Index = 2, Name = "Элемент выдавливания:1", Kind = "extrusion", Detail = "глубина 15.00 мм" }, + new FeatureInfo { Index = 3, Name = "Скругление:1", Kind = "fillet", Detail = "R3.00 мм" }, + }, + Variables = Array.Empty(), + }; + + var text = InspectionText.Render(d); + + Assert.Contains("Документ: «demo»", text); + Assert.Contains("Габарит (Д×Ш×В): 40.00 × 20.00 × 15.00 мм", text); + Assert.Contains("Тела: 1", text); + Assert.Contains("граней 3 (2 plane, 1 cylinder)", text); + Assert.Contains("Дерево построения (4", text); + Assert.Contains("«Элемент выдавливания:1» — глубина 15.00 мм", text); + Assert.Contains("«Скругление:1» — R3.00 мм", text); + Assert.Contains("Переменные: нет", text); + } + + // ── Рендер: импортированная модель (флаг Imported) ─────────────────── + [Fact] + public void Render_imported_model_notes_no_history() + { + var d = new ModelDescription + { + Name = "top_spacer", + IsEmpty = false, + Box = new BoundingBox { MinX = 0, MinY = 0, MinZ = 0, MaxX = 18, MaxY = 8, MaxZ = 39 }, + Mass = new PartInfo { Volume = 2463, Mass = 0.019, Area = 2053, CenterX = 0, CenterY = 0, CenterZ = 21 }, + Bodies = new[] { new BodyInfo { Index = 0, IsSolid = true, FaceCount = 61 } }, + FaceTypes = new[] { "plane", "cylinder" }, + EdgeTypes = new[] { "line" }, + Imported = true, + Features = new[] + { + new FeatureInfo { Index = 0, Name = "Начало координат", Kind = "origin" }, + new FeatureInfo { Index = 1, Name = "Body1", Kind = "body" }, + }, + Variables = Array.Empty(), + }; + + var text = InspectionText.Render(d); + + Assert.Contains("импортирован", text.ToLowerInvariant()); + Assert.DoesNotContain("Дерево построения (", text); // не показываем как параметрическое дерево + } + + // ── Рендер: пустая модель ──────────────────────────────────────────── + [Fact] + public void Render_empty_model() + { + var d = new ModelDescription + { + Name = "empty", IsEmpty = true, + Bodies = Array.Empty(), FaceTypes = Array.Empty(), EdgeTypes = Array.Empty(), + Imported = false, Features = Array.Empty(), Variables = Array.Empty(), + }; + + var text = InspectionText.Render(d); + + Assert.Contains("Модель пуста", text); + } + + // ── Рендер переменных ──────────────────────────────────────────────── + [Fact] + public void Render_variables_listed_with_flags() + { + var d = new ModelDescription + { + Name = "p", IsEmpty = false, + Box = new BoundingBox { MinX = 0, MinY = 0, MinZ = 0, MaxX = 1, MaxY = 1, MaxZ = 1 }, + Mass = new PartInfo { Volume = 1, Mass = 1, Area = 6, CenterX = 0, CenterY = 0, CenterZ = 0 }, + Bodies = new[] { new BodyInfo { Index = 0, IsSolid = true, FaceCount = 6 } }, + FaceTypes = new[] { "plane" }, EdgeTypes = new[] { "line" }, + Imported = false, Features = Array.Empty(), + Variables = new[] + { + new VariableInfo { Name = "L", Expression = "80", Value = 80, External = true, Information = false }, + new VariableInfo { Name = "v", Expression = "L/2", Value = 40, External = false, Information = true }, + }, + }; + + var text = InspectionText.Render(d); + + Assert.Contains("Переменные (2):", text); + Assert.Contains("L = 80", text); + Assert.Contains("[внешняя]", text); + Assert.Contains("[инфо]", text); + } +} diff --git a/tests/Kompas.Mcp.Tests/Integration/ConversionTests.cs b/tests/Kompas.Mcp.Tests/Integration/ConversionTests.cs new file mode 100644 index 0000000..e49ffb2 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/ConversionTests.cs @@ -0,0 +1,107 @@ +using Kompas.Mcp.Core.Conversion; +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Query; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: импорт/экспорт STEP и обход компонентов сборки. +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class ConversionTests +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly ConversionService _conv; + private readonly QueryService _query; + + public ConversionTests(KompasFixture fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _modeler = new PartModeler(fx.Session, fx.Dispatcher); + _conv = new ConversionService(fx.Session, fx.Dispatcher); + _query = new QueryService(fx.Session, fx.Dispatcher); + } + + private async Task BuildBoxAsync(double w, double d, double h) + { + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, 0, 0, w, d); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: h); + await _modeler.RebuildAsync(); + } + + [Fact] + public async Task Exports_active_part_to_step_file() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + await BuildBoxAsync(30, 20, 10); + var path = TestPaths.NewFile(".step"); + + await _conv.ExportStepAsync(path, StepFormat.Ap214); + + Assert.True(File.Exists(path), "STEP-файл не создан."); + Assert.True(new FileInfo(path).Length > 0, "STEP-файл пуст."); + } + finally + { + await _docs.CloseAsync(save: false); + } + } + + [Fact] + public async Task Imports_step_back_with_geometry() + { + // arrange: построить брусок и экспортировать в STEP + await _docs.CreateAsync(KompasDocumentType.Part); + string path = TestPaths.NewFile(".step"); + try + { + await BuildBoxAsync(40, 30, 10); + await _conv.ExportStepAsync(path, StepFormat.Auto); + } + finally + { + await _docs.CloseAsync(save: false); + } + + // act: импортировать обратно как деталь + await _conv.ImportStepAsync(path, KompasDocumentType.Part); + try + { + // assert: геометрия пришла — ненулевой объём (40·30·10 = 12000 мм³) + var info = await _query.GetPartInfoAsync(); + Assert.InRange(info.Volume, 12000 * 0.95, 12000 * 1.05); + } + finally + { + await _docs.CloseAsync(save: false); + } + } + + [Fact] + public async Task Lists_components_of_imported_assembly() + { + // Реальная сборка кейса (gitignored); если её нет локально — тест пропускается. + var caseStep = Path.Combine(TestPaths.RepoRoot(), + "usecases", "0002-voron-pug-strain-relief-2mm", "source", "G2E_PUG.step"); + if (!File.Exists(caseStep)) return; // gitignored кейс отсутствует локально — пропускаем + + await _conv.ImportStepAsync(caseStep, KompasDocumentType.Assembly); + try + { + var comps = await _query.ListComponentsAsync(); + + Assert.True(comps.Count >= 2, $"Ожидалось ≥2 компонента, получено {comps.Count}."); + Assert.All(comps, c => Assert.False(string.IsNullOrWhiteSpace(c.Name))); + Assert.Contains(comps, c => c.Name.Contains("spacer", StringComparison.OrdinalIgnoreCase)); + } + finally + { + await _docs.CloseAsync(save: false); + } + } +} diff --git a/tests/Kompas.Mcp.Tests/Integration/FaceEditTests.cs b/tests/Kompas.Mcp.Tests/Integration/FaceEditTests.cs new file mode 100644 index 0000000..94cb163 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/FaceEditTests.cs @@ -0,0 +1,51 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Editing; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Query; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: прямое редактирование — перемещение грани (FaceMover). +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class FaceEditTests +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly FaceEditService _faceEdit; + private readonly QueryService _query; + + public FaceEditTests(KompasFixture fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _modeler = new PartModeler(fx.Session, fx.Dispatcher); + _faceEdit = new FaceEditService(fx.Session, fx.Dispatcher); + _query = new QueryService(fx.Session, fx.Dispatcher); + } + + [Fact] + public async Task Move_face_grows_part_height_by_offset() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Брусок 30×20×10 на XOY: X[0;30], Y[0;20], Z[0;10]. Верхняя грань — Z=10, центр (15,10,10). + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, 0, 0, 30, 20); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: 10); + await _modeler.RebuildAsync(); + + // Толкаем верхнюю грань на +2 мм наружу → высота 10 → 12. + await _faceEdit.MoveFaceAsync(15, 10, 10, distance: 2); + await _modeler.RebuildAsync(); + + var bb = await _query.GetBoundingBoxAsync(); + Assert.InRange(bb.SizeZ, 11.9, 12.1); + } + finally + { + await _docs.CloseAsync(save: false); + } + } +} diff --git a/tests/Kompas.Mcp.Tests/Integration/InspectionTests.cs b/tests/Kompas.Mcp.Tests/Integration/InspectionTests.cs new file mode 100644 index 0000000..3eaa16f --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/InspectionTests.cs @@ -0,0 +1,114 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Query; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: структурное «зрение» — дерево, топология, drill-down, измерения. +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class InspectionTests +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly ModelInspectionService _inspect; + + public InspectionTests(KompasFixture fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _modeler = new PartModeler(fx.Session, fx.Dispatcher); + _inspect = new ModelInspectionService(fx.Session, fx.Dispatcher); + } + + /// Построить эталон: прямоугольник 40×20 → выдавить 15 мм → скругление R3 первого ребра. + private async Task BuildDemoPartAsync() + { + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, -20, -10, 20, 10); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: 15); + await _modeler.FilletEdgeIndexAsync(0, 3); + await _modeler.RebuildAsync(); + } + + [Fact] + public async Task Describe_model_shows_tree_topology_and_bodies() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + await BuildDemoPartAsync(); + + var d = await _inspect.DescribeModelAsync(); + + Assert.False(d.IsEmpty); + Assert.False(d.Imported); // родная параметрическая деталь + Assert.Single(d.Bodies); // одно тело + Assert.True(d.Bodies[0].IsSolid); + + // Габарит исходного бруска 40×20×15 (скругление R3 не меняет общий габарит). + Assert.NotNull(d.Box); + Assert.InRange(d.Box!.SizeX, 39.9, 40.1); + Assert.InRange(d.Box!.SizeZ, 14.9, 15.1); + + // Топология: есть плоскости и цилиндр от скругления. + Assert.Contains("plane", d.FaceTypes); + Assert.Contains("cylinder", d.FaceTypes); + + // Дерево: выдавливание с глубиной и скругление. + Assert.Contains(d.Features, f => f.Kind == "extrusion" && (f.Detail ?? "").Contains("15")); + Assert.Contains(d.Features, f => f.Kind == "fillet"); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task List_features_reports_extrusion_depth() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + await BuildDemoPartAsync(); + + var fs = await _inspect.ListFeaturesAsync(); + + var extrusion = Assert.Single(fs, f => f.Kind == "extrusion"); + Assert.Contains("15", extrusion.Detail ?? ""); + Assert.Contains(fs, f => f.Kind == "fillet"); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Describe_face_returns_area_and_edges() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + await BuildDemoPartAsync(); + + var face = await _inspect.DescribeFaceAsync(0); + + Assert.True(face.Area > 0); + Assert.True(face.EdgeCount > 0); + Assert.NotNull(face.Normal); + } + finally { await _docs.CloseAsync(save: false); } + } + + [Fact] + public async Task Measure_between_two_faces_succeeds() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + await BuildDemoPartAsync(); + + var m = await _inspect.MeasureAsync("face", 0, "face", 1); + + Assert.True(m.MinDistance >= 0); // путь измерения работает + Assert.True(double.IsFinite(m.Distance)); + } + finally { await _docs.CloseAsync(save: false); } + } +} diff --git a/tests/Kompas.Mcp.Tests/Integration/SolidEditTests.cs b/tests/Kompas.Mcp.Tests/Integration/SolidEditTests.cs new file mode 100644 index 0000000..a66e46f --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/SolidEditTests.cs @@ -0,0 +1,69 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Editing; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Query; +using Kompas.Mcp.Core.Validation; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: рассечение тела, перемещение тела, объединение (cut→spread→union). +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class SolidEditTests +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly SolidEditService _solid; + private readonly FaceEditService _face; + private readonly QueryService _query; + private readonly ValidationService _validation; + + public SolidEditTests(KompasFixture fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _modeler = new PartModeler(fx.Session, fx.Dispatcher); + _solid = new SolidEditService(fx.Session, fx.Dispatcher); + _face = new FaceEditService(fx.Session, fx.Dispatcher); + _query = new QueryService(fx.Session, fx.Dispatcher); + _validation = new ValidationService(fx.Session, fx.Dispatcher); + } + + [Fact] + public async Task Split_move_bridge_union_grows_height_cleanly() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Брусок 20×20×10 на XOY: Z[0;10]. + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, 0, 0, 20, 20); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: 10); + await _modeler.RebuildAsync(); + + // 1) Рассечь по XOY на Z=5 → 2 тела. + var bodies = await _solid.SplitByPlaneAsync(BasePlane.XOY, offset: 5); + Assert.Equal(2, bodies.Count); + + // 2) Сдвинуть верхнее тело (max Z) на +2 по Z. + var upper = bodies.OrderByDescending(b => b.MaxZ).First(); + await _solid.MoveBodyAsync(upper.Index, dz: 2); + + // 3) Мост: толкнуть грань реза нижнего тела (центр (10,10,5)) на +2 вверх до стыка. + await _face.MoveFaceAsync(10, 10, 5, distance: 2); + + // 4) Объединить тела в одно. + await _solid.UnionAllAsync(); + await _modeler.RebuildAsync(); + + // Итог: высота 10 → 12, единое тело, построение чистое. + var bb = await _query.GetBoundingBoxAsync(); + Assert.InRange(bb.SizeZ, 11.9, 12.1); + Assert.Empty(await _validation.ValidatePartAsync()); + } + finally + { + await _docs.CloseAsync(save: false); + } + } +} diff --git a/tests/Kompas.Mcp.Tests/Integration/ValidationTests.cs b/tests/Kompas.Mcp.Tests/Integration/ValidationTests.cs new file mode 100644 index 0000000..39cb0f0 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/ValidationTests.cs @@ -0,0 +1,44 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Validation; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: валидация построения (ошибки/перестроение операций). +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class ValidationTests +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly ValidationService _validation; + + public ValidationTests(KompasFixture fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _modeler = new PartModeler(fx.Session, fx.Dispatcher); + _validation = new ValidationService(fx.Session, fx.Dispatcher); + } + + [Fact] + public async Task Valid_part_reports_no_errors() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, 0, 0, 20, 20); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: 10); + await _modeler.RebuildAsync(); + + var errors = await _validation.ValidatePartAsync(); + + Assert.Empty(errors); + } + finally + { + await _docs.CloseAsync(save: false); + } + } +} diff --git a/tests/Kompas.Mcp.Tests/StepFormatTests.cs b/tests/Kompas.Mcp.Tests/StepFormatTests.cs new file mode 100644 index 0000000..99953cc --- /dev/null +++ b/tests/Kompas.Mcp.Tests/StepFormatTests.cs @@ -0,0 +1,34 @@ +using Kompas.Mcp.Core.Conversion; + +namespace Kompas.Mcp.Tests; + +/// Юнит-тесты маппинга формата STEP в коды конвертера КОМПАС. COM не требуется. +[Trait("Category", "Unit")] +public sealed class StepFormatTests +{ + [Theory] + [InlineData(StepFormat.Auto, 3)] // ksConverterToSTEP + [InlineData(StepFormat.Ap203, 203)] // ksConverterToSTEP_AP203 + [InlineData(StepFormat.Ap214, 214)] // ksConverterToSTEP_AP214 + [InlineData(StepFormat.Ap242, 242)] // ksConverterToSTEP_AP242 + public void Export_code_matches_converter_enum(StepFormat fmt, int expected) + => Assert.Equal(expected, StepFormats.ExportConverterCode(fmt)); + + [Fact] + public void Import_code_is_ksConverterFromStep() + => Assert.Equal(-3, StepFormats.ImportConverterCode); + + [Theory] + [InlineData("auto", StepFormat.Auto)] + [InlineData("step", StepFormat.Auto)] + [InlineData("ap214", StepFormat.Ap214)] + [InlineData("214", StepFormat.Ap214)] + [InlineData("AP203", StepFormat.Ap203)] + [InlineData("ap242", StepFormat.Ap242)] + public void Parse_maps_known_strings(string input, StepFormat expected) + => Assert.Equal(expected, StepFormats.Parse(input)); + + [Fact] + public void Parse_rejects_unknown_format() + => Assert.Throws(() => StepFormats.Parse("iges")); +} diff --git a/tools/build_kompas3d_sdk.py b/tools/build_kompas3d_sdk.py index 9ce3d7a..b3d13cf 100644 --- a/tools/build_kompas3d_sdk.py +++ b/tools/build_kompas3d_sdk.py @@ -8,7 +8,11 @@ build_kompas3d_sdk.py — Преобразование KOMPAS SDK HTML-спра - Группировка: одна MD-статья на COM-интерфейс/тему (по префиксу имени файла). - Изображения: копируются файлами в resources/ (не base64). - KsAPI (ksapi_*.html): пропускаются. - - Фронтматтер: type, api, domain, tags, sources. + - TOC-страницы (- методы, - свойства): пропускаются. + - Таблицы: рендерятся как MD-таблицы. + - Синтаксис/примеры: оборачиваются в code-fence блоки. + - Навигационный chrome: пропускается (breadcrumbs, nav-box, footer). + - Фронтматтер: type, api, domain, tags, sources (YAML-safe escaping). Запуск (из корня репо): python tools/build_kompas3d_sdk.py @@ -46,9 +50,31 @@ NOISE_WORDS = { "подразделы:", "(отсутствуют)", "поделиться", } FOOTER_RE = re.compile(r"©?\s*ООО\s*«?АСКОН") +# Breadcrumb lines to drop (they repeat in every section heading) +BREADCRUMB_RE = re.compile( + r"^(KOMPAS SDK|API интерфейсов|KsAPI|Справочник|Версия \d|" + r"Automation|COM|Объект|Параметры 3D|Параметры 2D).*?>", + re.IGNORECASE, +) + +# ─── TOC-страницы (пропускаются — дублируют содержимое) ───────────────────── + +_TOC_SUFFIXES = ( + " - методы", " - свойства", " - события", " - конструкторы", + " - члены", " - перечисляемые", + " methods", " properties", " events", " members", +) +_TOC_EXACT = {"методы", "свойства", "события", "конструкторы"} -# ─── Утилиты ──────────────────────────────────────────────────────────────── +def is_toc_page_title(title: str) -> bool: + t = title.strip().lower() + if t in _TOC_EXACT: + return True + return any(t.endswith(s.lower()) for s in _TOC_SUFFIXES) + + +# ─── Утилиты изображений ───────────────────────────────────────────────────── def is_informative_image(src: str) -> bool: basename = os.path.basename(src).lower() @@ -61,52 +87,203 @@ def is_informative_image(src: str) -> bool: return True -class TopicExtractor(HTMLParser): - BLOCK_TAGS = {"p", "div", "br", "tr", "li", "h1", "h2", "h3", "h4", - "table", "pre", "td", "th"} +def copy_image(img_path: Path) -> str: + """Копирует изображение в resources/ с collision-safe именованием.""" + RESOURCES_DIR.mkdir(parents=True, exist_ok=True) + try: + rel = img_path.relative_to(DOC_DIR) + dest_name = str(rel).replace(os.sep, "_").replace("/", "_") + except ValueError: + dest_name = img_path.name + dest = RESOURCES_DIR / dest_name + if not dest.exists(): + shutil.copy2(img_path, dest) + return dest_name + + +# ─── HTML → MD парсер (таблицы, skip-зоны,
/) ───────────────────
+
+class HtmlToMdExtractor(HTMLParser):
+    """
+    Парсер контентной зоны Help & Manual WebHelp:
+    - Захватывает только div#hmpagebody_scroller (содержимое без навигации).
+    - Рендерит  в Markdown-таблицы.
+    - Сохраняет 
 как code-fence,  как inline-code.
+
+    Стратегия входа: ищем id="hmpagebody_scroller" (содержательная зона без
+    chrome). Навигационный header (hmpageheader), breadcrumbs и footer
+    оказываются СНАРУЖИ этого div и автоматически пропускаются.
+    Это надёжнее, чем skip-зоны, т.к. HTML может содержать незакрытые теги
+    в навигационной части (Help & Manual особенность).
+    """
+    BLOCK_TAGS = {"p", "div", "br", "li", "h1", "h2", "h3", "h4", "pre"}
     VOID_TAGS = {"img", "input", "link", "meta", "hr", "area", "base",
                  "col", "embed", "source", "track", "wbr"}
+    # IDs которые служат точкой входа (по убыванию специфичности)
+    ENTRY_IDS = ("hmpagebody_scroller", "hmpagebody", "topicbody")
 
     def __init__(self):
         super().__init__(convert_charrefs=True)
-        self.depth = 0
-        self.capturing = False
-        self.skip = 0
-        self.out = []
+        # Capture state
+        self.depth: int = 0
+        self.capturing: bool = False
+        self.skip_script: int = 0
+        # Table
+        self.table_depth: int = 0
+        self.table_rows: list = []
+        self.cur_cells: list = []
+        self.cur_cell: list = []
+        self.cell_is_header: bool = False
+        # Pre / code
+        self.in_pre: bool = False
+        self.code_depth: int = 0
+        # Output
+        self.out: list[str] = []
 
-    def handle_starttag(self, tag, attrs):
+    def handle_starttag(self, tag: str, attrs):
         attrd = dict(attrs)
-        if not self.capturing and attrd.get("id") == "topicbody":
-            self.capturing = True
-            self.depth = 1
-            return
+
+        # Try to activate capturing on the content div
         if not self.capturing:
+            elem_id = attrd.get("id", "")
+            if elem_id in self.ENTRY_IDS:
+                self.capturing = True
+                self.depth = 1
             return
+
+        # Table
+        if tag == "table":
+            self.table_depth += 1
+            if self.table_depth == 1:
+                self.table_rows = []
+                self.cur_cells = []
+                self.cur_cell = []
+            return
+        if self.table_depth > 0:
+            if tag == "tr":
+                self.cur_cells = []
+                self.cell_is_header = False
+            elif tag == "th":
+                self.cell_is_header = True
+                self.cur_cell = []
+            elif tag == "td":
+                self.cur_cell = []
+            return
+
+        # Script / style
         if tag in ("script", "style"):
-            self.skip += 1
+            self.skip_script += 1
+            return
+
+        # Pre
+        if tag == "pre":
+            self.in_pre = True
+            self.out.append("\n```\n")
+            return
+
+        # Inline code
+        if tag == "code" and not self.in_pre:
+            self.code_depth += 1
+            self.out.append("`")
+            return
+
+        # Block separators
         if tag in self.BLOCK_TAGS:
             self.out.append("\n")
-        if tag not in ("br",) and tag not in self.VOID_TAGS:
+
+        # Depth tracking (for exit detection only)
+        if tag not in self.VOID_TAGS and tag != "br":
             self.depth += 1
 
-    def handle_endtag(self, tag):
+    def handle_endtag(self, tag: str):
         if not self.capturing:
             return
-        if tag in ("script", "style") and self.skip:
-            self.skip -= 1
-        if tag != "br":
+
+        # Table exit
+        if self.table_depth > 0:
+            if tag in ("td", "th"):
+                cell_text = " ".join("".join(self.cur_cell).split())
+                self.cur_cells.append(cell_text)
+                self.cur_cell = []
+            elif tag == "tr":
+                if self.cur_cells:
+                    self.table_rows.append((self.cell_is_header, list(self.cur_cells)))
+                self.cur_cells = []
+            elif tag == "table":
+                self.table_depth -= 1
+                if self.table_depth == 0:
+                    md_tbl = self._render_table()
+                    if md_tbl:
+                        self.out.append("\n" + md_tbl + "\n")
+                    self.table_rows = []
+            return
+
+        # Script / style
+        if tag in ("script", "style") and self.skip_script:
+            self.skip_script -= 1
+            return
+
+        # Pre
+        if tag == "pre":
+            if self.out and not self.out[-1].endswith("\n"):
+                self.out.append("\n")
+            self.out.append("```\n")
+            self.in_pre = False
+            return
+
+        # Inline code
+        if tag == "code" and self.code_depth > 0:
+            self.code_depth -= 1
+            self.out.append("`")
+            return
+
+        if tag not in self.VOID_TAGS and tag != "br":
             self.depth -= 1
         if self.depth <= 0:
             self.capturing = False
 
-    def handle_data(self, data):
-        if self.capturing and not self.skip:
-            self.out.append(data)
+    def handle_data(self, data: str):
+        if not self.capturing or self.skip_script:
+            return
+        if self.table_depth > 0:
+            self.cur_cell.append(data)
+            return
+        self.out.append(data)
 
+    def _render_table(self) -> str:
+        if not self.table_rows:
+            return ""
+        max_cols = max((len(r[1]) for r in self.table_rows), default=0)
+        if max_cols == 0:
+            return ""
+        lines: list[str] = []
+        header_done = False
+        for _is_hdr, cells in self.table_rows:
+            while len(cells) < max_cols:
+                cells.append("")
+            cells = cells[:max_cols]
+            lines.append("| " + " | ".join(cells) + " |")
+            if not header_done:
+                lines.append("| " + " | ".join(["---"] * max_cols) + " |")
+                header_done = True
+        return "\n".join(lines)
+
+
+# ─── Очистка текста ──────────────────────────────────────────────────────────
 
 def clean_text(raw: str) -> list[str]:
-    cleaned = []
+    """Очищает текст; сохраняет code-fence блоки без изменений."""
+    cleaned: list[str] = []
+    in_fence = False
     for line in raw.splitlines():
+        stripped = line.strip()
+        if stripped.startswith("```"):
+            in_fence = not in_fence
+            cleaned.append(line)
+            continue
+        if in_fence:
+            cleaned.append(line)
+            continue
         s = re.sub(r"[ \t]+", " ", line).strip()
         if not s:
             continue
@@ -114,10 +291,101 @@ def clean_text(raw: str) -> list[str]:
             continue
         if FOOTER_RE.search(s):
             break
+        if ">" in s and BREADCRUMB_RE.match(s):
+            continue
         cleaned.append(s)
+    if in_fence:
+        cleaned.append("```")
     return cleaned
 
 
+# ─── Code-block постпроцессинг ───────────────────────────────────────────────
+
+_CODE_STARTERS: dict[str, str] = {}
+for _k in ("синтаксис automation:", "синтаксис automation",
+           "синтаксис com:", "синтаксис com",
+           "синтаксис:", "синтаксис"):
+    _CODE_STARTERS[_k] = ""
+for _k in ("пример:", "пример", "примеры:", "примеры",
+           "examples:", "example:", "example"):
+    _CODE_STARTERS[_k] = "vb"
+
+_CODE_ENDERS = frozenset({
+    "параметры:", "параметры", "возвращает:", "возвращает",
+    "описание:", "описание", "примечание:", "примечание",
+    "замечание:", "замечание", "замечания:", "замечания",
+    "примечания:", "дополнительные сведения:", "смотри также:", "смотри также",
+    "требования:", "требования",
+})
+
+
+def postprocess_code_blocks(lines: list[str]) -> list[str]:
+    """
+    Оборачивает секции «Синтаксис» и «Пример» в MD code-fence блоки.
+    Существующие ``` fence-блоки (от 
/) пропускаются.
+    """
+    result: list[str] = []
+    in_code = False
+    existing_fence = False
+    i = 0
+
+    while i < len(lines):
+        line = lines[i]
+        stripped = line.strip()
+        lower = stripped.lower()
+
+        # Пропускаем существующие fence-блоки (из 
)
+        if stripped.startswith("```"):
+            existing_fence = not existing_fence
+            result.append(line)
+            i += 1
+            continue
+        if existing_fence:
+            result.append(line)
+            i += 1
+            continue
+
+        # Заголовок, открывающий новый code-block
+        if lower in _CODE_STARTERS:
+            if in_code:
+                result.append("```")
+            result.append(stripped)
+            lang = _CODE_STARTERS[lower]
+            result.append(f"```{lang}")
+            in_code = True
+            i += 1
+            continue
+
+        # Заголовок, закрывающий текущий code-block (не стартер)
+        if in_code and lower in _CODE_ENDERS:
+            result.append("```")
+            in_code = False
+            continue  # повторно обрабатываем эту строку (теперь in_code=False)
+
+        # Пустая строка внутри code-block
+        if in_code and not stripped:
+            j = i + 1
+            while j < len(lines) and not lines[j].strip():
+                j += 1
+            nxt = lines[j].strip().lower() if j < len(lines) else ""
+            if nxt in _CODE_ENDERS or nxt in _CODE_STARTERS or not lines[i + 1:]:
+                result.append("```")
+                in_code = False
+                result.append("")
+            else:
+                result.append(line)
+            i += 1
+            continue
+
+        result.append(line)
+        i += 1
+
+    if in_code:
+        result.append("```")
+    return result
+
+
+# ─── Заголовок и хлебные крошки ──────────────────────────────────────────────
 
 def extract_title_and_breadcrumbs(html_content: str) -> tuple[str, list[tuple[str, str]]]:
     title = ""
@@ -125,17 +393,18 @@ def extract_title_and_breadcrumbs(html_content: str) -> tuple[str, list[tuple[st
         title = hmod.unescape(m.group(1)).strip()
         break
     if not title:
-        for m in re.finditer(r']*>(.*?)

', html_content, re.DOTALL): + for m in re.finditer( + r']*>(.*?)

', html_content, re.DOTALL): title = re.sub(r'<[^>]+>', '', m.group(1)).strip() title = hmod.unescape(title) break - breadcrumbs = [] + breadcrumbs: list[tuple[str, str]] = [] bc_match = re.search( - r']*>(.*?)

', - html_content, re.DOTALL) + r']*>(.*?)

', html_content, re.DOTALL) if bc_match: bc_html = bc_match.group(1) - for a_m in re.finditer(r']+href="([^"]*)"[^>]*>(.*?)', bc_html, re.DOTALL): + for a_m in re.finditer( + r']+href="([^"]*)"[^>]*>(.*?)', bc_html, re.DOTALL): href = a_m.group(1) text = re.sub(r'<[^>]+>', '', a_m.group(2)).strip() text = hmod.unescape(text) @@ -144,16 +413,18 @@ def extract_title_and_breadcrumbs(html_content: str) -> tuple[str, list[tuple[st return title, breadcrumbs +# ─── Индекс ────────────────────────────────────────────────────────────────── + def read_index() -> list[dict]: - pages = [] + pages: list[dict] = [] with open(INDEX_PATH, "r", encoding="utf-8") as f: for line in f: parts = line.rstrip("\n").split("\t") if len(parts) >= 1 and parts[0]: pages.append({ - "file": parts[0], + "file": parts[0], "title": parts[1] if len(parts) > 1 else "", - "desc": parts[2] if len(parts) > 2 else "", + "desc": parts[2] if len(parts) > 2 else "", }) return pages @@ -162,14 +433,11 @@ def read_index() -> list[dict]: def get_group_key(filename: str) -> str: """ - Извлекает ключ группы из имени файла. - Стратегия: часть до первого '_' (после удаления ведущего '_'). - Примеры: - 'iapplication_visible.html' → 'iapplication' - 'iapplication_visible_ex.html' → 'iapplication' - '_dkgax_caption.html' → 'dkgax' - '3d_curves.html' → '3d' - 'zoomtypeenum.html' → 'zoomtypeenum' (standalone) + Ключ = часть до первого '_' (удаляя ведущий '_'). + 'iapplication_visible.html' → 'iapplication' + '_dkgax_caption.html' → 'dkgax' + '3d_curves.html' → '3d' + 'zoomtypeenum.html' → 'zoomtypeenum' """ stem = filename.replace(".html", "") if stem.startswith("_"): @@ -179,15 +447,14 @@ def get_group_key(filename: str) -> str: def build_groups(pages: list[dict]) -> dict[str, list[dict]]: - """ - Разбивает страницы на группы по ключу. - Пропускает ksapi_* (KsAPI — Qt/C++ cross-platform, не нужен). - """ + """Группирует страницы, пропуская KsAPI и TOC-страницы.""" raw: dict[str, list[dict]] = defaultdict(list) for page in pages: fname = page["file"] if fname.startswith("ksapi_"): continue + if is_toc_page_title(page.get("title", "")): + continue key = get_group_key(fname) raw[key].append(page) return dict(raw) @@ -196,24 +463,35 @@ def build_groups(pages: list[dict]) -> dict[str, list[dict]]: # ─── Классификация ─────────────────────────────────────────────────────────── def classify_type(group_key: str, representative_title: str) -> str: - """Определяет тип: 'interface', 'enum', 'struct', 'guide'.""" + """Определяет тип статьи: 'interface', 'enum', 'struct', 'guide'.""" key = group_key.lower() title = representative_title.lower() + # Enum — специфичный признак, проверяем первым if key.endswith("enum") or "enum" in title or "перечислени" in title: return "enum" + + # Interface — сначала по тексту заголовка (ПЕРЕД проверкой суффикса ключа!) + # Исправляет ложную классификацию ksXxxDefinition как struct + if any(w in title for w in ("интерфейс", "интерфейсы", "automation", "idispatch")): + return "interface" + + # Interface — COM-конвенция (начинается с 'i' + строчная) if re.match(r"^i[a-z]", key): return "interface" - if any(s in key for s in ["param", "definition", "struct"]): + + # Struct / параметры + if any(s in key for s in ("param", "definition", "struct")): return "struct" - if any(s in title for s in ["параметры", "структура", "param "]): + if any(s in title for s in ("параметры", "структура", "param ")): return "struct" + return "guide" def extract_api_version(title: str, breadcrumbs: list[tuple[str, str]]) -> list[str]: all_text = (title + " " + " ".join(t for t, _ in breadcrumbs)).lower() - versions = [] + versions: list[str] = [] if "версия 7" in all_text or "api7" in all_text or "api 7" in all_text: versions.append("api7") if "api5" in all_text or "api 5" in all_text: @@ -222,8 +500,10 @@ def extract_api_version(title: str, breadcrumbs: list[tuple[str, str]]) -> list[ def extract_domain_tags(title: str, breadcrumbs: list[tuple[str, str]]) -> list[str]: - all_text = (title + " " + " ".join(t for t, _ in breadcrumbs)).lower() - domain_map = { + # Вес: заголовок + последние 2 breadcrumb (более точно, чем все предки) + last_bc = " ".join(t for t, _ in breadcrumbs[-2:]) if breadcrumbs else "" + all_text = (title + " " + last_bc).lower() + domain_map: dict[str, list[str]] = { "3d": ["3d", "трёхмерн", "3д", "solid", "тело"], "2d": ["2d", "двухмерн", "2д", "плоск"], "sketch": ["эскиз"], @@ -232,7 +512,7 @@ def extract_domain_tags(title: str, breadcrumbs: list[tuple[str, str]]) -> list[ "drawing": ["чертёж", "чертеж", "drawing", "выносн"], "feature": ["операци", "feature", "выдавлив", "вращени", "скругл", "фаск"], } - found = [] + found: list[str] = [] for domain, keywords in domain_map.items(): if any(k in all_text for k in keywords): found.append(domain) @@ -245,10 +525,10 @@ def build_tags(article_type: str, api_versions: list[str], return list(dict.fromkeys(tags)) -# ─── Парсинг HTML ──────────────────────────────────────────────────────────── +# ─── Парсинг HTML-страницы ─────────────────────────────────────────────────── def parse_page(fpath: Path) -> Optional[dict]: - """Парсит одну HTML-страницу. Возвращает None если страница пустая.""" + """Парсит HTML-страницу. Возвращает None если контент пустой.""" try: html_content = fpath.read_text(encoding="utf-8-sig") except Exception as e: @@ -257,7 +537,7 @@ def parse_page(fpath: Path) -> Optional[dict]: title, breadcrumbs = extract_title_and_breadcrumbs(html_content) - extractor = TopicExtractor() + extractor = HtmlToMdExtractor() extractor.feed(html_content) raw_text = "".join(extractor.out) @@ -265,6 +545,7 @@ def parse_page(fpath: Path) -> Optional[dict]: if not body_lines or len(body_lines) <= 2: return None + # Информативные изображения image_srcs: list[tuple[Path, str]] = [] for m in re.finditer(r']+>', html_content): tag = m.group() @@ -279,9 +560,6 @@ def parse_page(fpath: Path) -> Optional[dict]: if img_path.exists(): image_srcs.append((img_path, alt)) - # HTML entities are already decoded by TopicExtractor(convert_charrefs=True) - # No need to unescape again - return { "title": title, "breadcrumbs": breadcrumbs, @@ -290,24 +568,7 @@ def parse_page(fpath: Path) -> Optional[dict]: } -# ─── Изображения ───────────────────────────────────────────────────────────── - -def copy_image(img_path: Path) -> str: - """Копирует изображение в resources/ и возвращает уникальное имя файла.""" - RESOURCES_DIR.mkdir(parents=True, exist_ok=True) - # Build collision-safe name from relative path: "images/foo.png" → "images_foo.png" - try: - rel = img_path.relative_to(DOC_DIR) - dest_name = str(rel).replace(os.sep, "_").replace("/", "_") - except ValueError: - dest_name = img_path.name # fallback if not under DOC_DIR - dest = RESOURCES_DIR / dest_name - if not dest.exists(): - shutil.copy2(img_path, dest) - return dest_name - - -# ─── Генерация MD ──────────────────────────────────────────────────────────── +# ─── Генерация MD-статьи ────────────────────────────────────────────────────── SUBDIR_FOR_TYPE = { "interface": "interfaces", @@ -317,6 +578,11 @@ SUBDIR_FOR_TYPE = { } +def _escape_yaml_str(s: str) -> str: + """Экранирует строку для безопасного использования в YAML double-quoted значении.""" + return s.replace("\\", "\\\\").replace('"', '\\"') + + def render_article(group_key: str, pages_data: list[dict], article_type: str, source_files: list[str]) -> str: """Генерирует содержимое MD-файла для группы страниц.""" @@ -328,9 +594,10 @@ def render_article(group_key: str, pages_data: list[dict], domain_tags = extract_domain_tags(title, breadcrumbs) tags = build_tags(article_type, api_versions, domain_tags, group_key) + # YAML frontmatter с безопасным экранированием строк fm_lines = [ "---", - f'title: "{title}"', + f'title: "{_escape_yaml_str(title)}"', f"type: {article_type}", f"api: [{', '.join(api_versions)}]", ] @@ -345,27 +612,69 @@ def render_article(group_key: str, pages_data: list[dict], md_parts = ["\n".join(fm_lines)] + # Breadcrumbs — один раз в начале статьи if breadcrumbs: bc_text = " > ".join(t for t, _ in breadcrumbs) md_parts.append(f"*{bc_text}*\n") for page_data in pages_data: page_title = page_data["title"] + body_lines = list(page_data["body_lines"]) + # Подзаголовок раздела для группы > 1 страницы if len(pages_data) > 1: md_parts.append(f"\n## {page_title}\n") + # Убираем дублирующуюся первую строку тела + if body_lines and body_lines[0].strip() == page_title.strip(): + body_lines = body_lines[1:] + # Применяем code-fence постпроцессинг + body_lines = postprocess_code_blocks(body_lines) + + # Изображения for img_path, alt in page_data["image_srcs"]: img_name = copy_image(img_path) alt_clean = alt.replace('"', "'") md_parts.append(f"![{alt_clean}](../resources/{img_name})\n") - md_parts.append("\n".join(page_data["body_lines"])) + md_parts.append("\n".join(body_lines)) md_parts.append("") return "\n".join(md_parts) +# ─── Генерация index.md ─────────────────────────────────────────────────────── + +def _write_index(entries: list[tuple[str, str, str]]): + """Генерирует docs/Kompas3D_SDK/index.md с TOC по 4 категориям.""" + lines = [ + "# КОМПАС-3D SDK — База знаний\n", + "> Автоматически сгенерировано из `docs/KOMPAS_SDK_ru-RU/`" + " скриптом `build_kompas3d_sdk.py`.\n", + "", + ] + categories = { + "interfaces": "## Интерфейсы (COM API)", + "enums": "## Перечисления", + "structures": "## Структуры и параметры", + "guides": "## Руководства и концепции", + } + by_subdir: dict[str, list[tuple[str, str]]] = defaultdict(list) + for subdir, fname, title in sorted(entries, key=lambda x: x[2].lower()): + by_subdir[subdir].append((fname, title)) + + for subdir, header in categories.items(): + if subdir not in by_subdir: + continue + lines.append(header) + for fname, title in by_subdir[subdir]: + lines.append(f"- [{_escape_yaml_str(title)}](./{subdir}/{fname})") + lines.append("") + + (OUT_DIR / "index.md").write_text("\n".join(lines), encoding="utf-8") + print(f" index.md: {len(entries)} ссылок") + + # ─── Главная функция ────────────────────────────────────────────────────────── def main(): @@ -388,7 +697,7 @@ def main(): groups = build_groups(pages) print(f"Групп после группировки: {len(groups)}") - stats = {"processed": 0, "skipped": 0, "errors": 0, "images_copied": 0} + stats = {"processed": 0, "skipped": 0, "errors": 0, "images": 0} index_entries: list[tuple[str, str, str]] = [] for group_key, group_pages in sorted(groups.items()): @@ -410,7 +719,7 @@ def main(): source_files.append(fname) if not rep_title: rep_title = parsed["title"] - stats["images_copied"] += len(parsed["image_srcs"]) + stats["images"] += len(parsed["image_srcs"]) if not pages_data: continue @@ -435,36 +744,7 @@ def main(): print(f" Статей записано: {stats['processed']}") print(f" Страниц пропущено: {stats['skipped']}") print(f" Ошибок: {stats['errors']}") - print(f" Изображений: {stats['images_copied']}") - - -def _write_index(entries: list[tuple[str, str, str]]): - """Генерирует docs/Kompas3D_SDK/index.md с оглавлением по категориям.""" - lines = [ - "# КОМПАС-3D SDK — База знаний\n", - "> Автоматически сгенерировано из `docs/KOMPAS_SDK_ru-RU/` скриптом `build_kompas3d_sdk.py`.\n", - "", - ] - categories = { - "interfaces": "## Интерфейсы (COM API)", - "enums": "## Перечисления", - "structures": "## Структуры и параметры", - "guides": "## Руководства и концепции", - } - by_subdir: dict[str, list[tuple[str, str]]] = defaultdict(list) - for subdir, fname, title in sorted(entries, key=lambda x: x[2]): - by_subdir[subdir].append((fname, title)) - - for subdir, header in categories.items(): - if subdir not in by_subdir: - continue - lines.append(header) - for fname, title in by_subdir[subdir]: - lines.append(f"- [{title}](./{subdir}/{fname})") - lines.append("") - - (OUT_DIR / "index.md").write_text("\n".join(lines), encoding="utf-8") - print(f" index.md: {len(entries)} ссылок") + print(f" Изображений: {stats['images']}") if __name__ == "__main__": diff --git a/tools/convert_sdk_docs.py b/tools/convert_sdk_docs.py new file mode 100644 index 0000000..08519a7 --- /dev/null +++ b/tools/convert_sdk_docs.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +convert_sdk_docs.py — Экспорт справки KOMPAS SDK (Help & Manual WebHelp) в единый +KOMPAS_SDK_ru-RU.md. + +Обрабатывает все HTML-страницы из docs/KOMPAS_SDK_ru-RU/, извлекая: + - Заголовок, хлебные крошки, тело топика + - Информативные изображения (сохраняются как base64 data-URI) + - Ссылки между страницами (преобразуются в якоря #page-N) + +Результат: docs/KOMPAS_SDK_ru-RU.md — один большой Markdown-файл. + +Изображения сохраняются ТОЛЬКО информативные (не UI/навигационные). +""" +import os +import sys +import re +import json +import base64 +import html as hmod +from html.parser import HTMLParser +from pathlib import Path + +# ─── Настройки ─────────────────────────────────────────────────────────────── + +HERE = Path(__file__).resolve().parent.parent +DOC_DIR = HERE / "docs" / "KOMPAS_SDK_ru-RU" +INDEX_PATH = HERE / "docs" / "kompas_sdk_index.tsv" +OUTPUT_MD = HERE / "docs" / "KOMPAS_SDK_ru-RU.md" + +# Изображения, которые НЕ являются информативными (UI-элементы навигации и т.п.) +UI_IMAGE_PREFIXES = [ + # Навигация / UI + "leftright", "toc", "mob_", "TextPlus", "TextMinus", "TextNormal", + "ZoomIn", "ZoomClose", "spacer.gif", "bg.png", "page-bg.png", + "blackpaisley.jpeg", "banner_company", "kompas_logo_circle", + "kompas-logo", "internet.svg", "icon-new-window", "icon-open-file", + "warn", "note", "closehelp", "cicon_loadindex_ani", "kw-page", + "social_mail", "_a", "_praezession", "kbd.png", "page-icon", + "logo-image-desk", "logo-image-mobile", "close_data", "open_data", +] + + +# ─── Поиск информативных изображений ──────────────────────────────────────── + +def is_informative_image(src: str) -> bool: + """Возвращает True, если изображение информативное (не UI).""" + basename = os.path.basename(src).lower() + for prefix in UI_IMAGE_PREFIXES: + if prefix.lower() in basename: + return False + # Также пропускаем tiny-изображения (< 500 байт) — обычно это spacer/иконки + img_path = DOC_DIR / src + if img_path.exists() and img_path.stat().st_size < 500: + return False + return True + + +# ─── Парсер для извлечения тела топика ────────────────────────────────────── + +class TopicExtractor(HTMLParser): + """Извлекает текст из
, пропуская script/style.""" + + BLOCK_TAGS = {"p", "div", "br", "tr", "li", "h1", "h2", "h3", "h4", + "table", "pre", "td", "th"} + + def __init__(self): + super().__init__(convert_char_refs=True) + self.depth = 0 + self.capturing = False + self.skip = 0 + self.out = [] + + def handle_starttag(self, tag, attrs): + attrd = dict(attrs) + if not self.capturing and attrd.get("id") == "topicbody": + self.capturing = True + self.depth = 1 + return + if not self.capturing: + return + if tag in ("script", "style"): + self.skip += 1 + if tag in self.BLOCK_TAGS: + self.out.append("\n") + if tag != "br": + self.depth += 1 + + def handle_endtag(self, tag): + if not self.capturing: + return + if tag in ("script", "style") and self.skip: + self.skip -= 1 + if tag != "br": + self.depth -= 1 + if self.depth <= 0: + self.capturing = False + + def handle_data(self, data): + if self.capturing and not self.skip: + self.out.append(data) + + +# ─── Шум и подвал ──────────────────────────────────────────────────────────── + +NOISE_WORDS = { + "please enable javascript to view this site.", + "scroll", "предыдущий", "вверх", "следующий", + "содержание", "содержане", "указатель", "поиск", + "подразделы:", "(отсутствуют)", "поделиться", +} +FOOTER_RE = re.compile(r"©?\s*ООО\s*«?АСКОН") + + +def clean_text(raw: str) -> list[str]: + """Очищает текст от шума и подвала, возвращает список строк.""" + cleaned = [] + for line in raw.splitlines(): + s = re.sub(r"[ \t]+", " ", line).strip() + if not s: + continue + if s.lower() in NOISE_WORDS: + continue + if FOOTER_RE.search(s): + break + cleaned.append(s) + return cleaned + + +# ─── Извлечение изображений как base64 data-URI ────────────────────────────── + +def extract_images(html_content: str) -> list[tuple[str, str]]: + """ + Возвращает список (data_uri, alt_text) для информативных изображений. + data_uri — строка вида 'data:image/png;base64,...' + """ + images = [] + for m in re.finditer(r']+>', html_content): + tag = m.group() + src_m = re.search(r'''src=["']([^"']+)["']''', tag) + alt_m = re.search(r'''alt=["']([^"']*)["']''', tag) + if not src_m: + continue + src = src_m.group(1) + alt = alt_m.group(1) if alt_m else "" + + if not is_informative_image(src): + continue + + img_path = DOC_DIR / src + if not img_path.exists(): + continue + + ext = os.path.splitext(src)[1].lower().lstrip(".") + mime_map = { + "jpg": "image/jpeg", "jpeg": "image/jpeg", + "png": "image/png", "gif": "image/gif", + "svg": "image/svg+xml", "webp": "image/webp", + "bmp": "image/bmp", + } + mime = mime_map.get(ext, f"image/{ext}") + + data = base64.b64encode(img_path.read_bytes()).decode("ascii") + data_uri = f"data:{mime};base64,{data}" + images.append((data_uri, alt)) + + return images + + +# ─── Извлечение заголовка и хлебных крошек ─────────────────────────────────── + +def extract_title_and_breadcrumbs(html_content: str) -> tuple[str, list[tuple[str, str]]]: + """ + Возвращает (title, breadcrumbs). + breadcrumbs — список (текст, filename) для каждой ступени. + """ + # Title из или <p class="topictitle"> + title = "" + for m in re.finditer(r'<title>(.*?)', html_content): + title = hmod.unescape(m.group(1)).strip() + break + if not title: + for m in re.finditer(r']*>(.*?)

', html_content, re.DOTALL): + title = re.sub(r'<[^>]+>', '', m.group(1)).strip() + title = hmod.unescape(title) + break + + # Breadcrumbs из

+ breadcrumbs = [] + bc_match = re.search( + r']*>(.*?)

', + html_content, re.DOTALL, + ) + if bc_match: + bc_html = bc_match.group(1) + for a_m in re.finditer(r']+href="([^"]*)"[^>]*>(.*?)', bc_html, re.DOTALL): + href = a_m.group(1) + text = re.sub(r'<[^>]+>', '', a_m.group(2)).strip() + text = hmod.unescape(text) + if text: + breadcrumbs.append((text, os.path.basename(href))) + + return title, breadcrumbs + + +# ─── Преобразование ссылок в якоря ────────────────────────────────────────── + +def convert_links(md_lines: list[str], page_index: int) -> list[str]: + """Преобразует [текст](file.html) → [текст](#page-{index}) в Markdown.""" + result = [] + for line in md_lines: + # Преобразуем ссылки на другие HTML-страницы в якоря + def replace_link(m): + href = m.group(1) + text = m.group(2) + fname = os.path.basename(href).replace(".html", "") + return f"[{text}](#page-{fname})" + + line = re.sub(r'\[([^\]]+)\]\(([^)]+\.html[^)]*)\)', replace_link, line) + result.append(line) + return result + + +# ─── Преобразование HTML-сущностей в читаемый текст ────────────────────────── + +def unescape_html(s: str) -> str: + """Полностью раскрывает HTML-сущности.""" + # Сначала   и неразрывные пробелы + s = s.replace(" ", " ") + s = s.replace(" ", " ") + s = s.replace("­", "") + s = s.replace("—", "—") + s = s.replace("–", "–") + s = s.replace("«", "«") + s = s.replace("»", "»") + s = s.replace(""", '"') + s = s.replace("'", "'") + s = s.replace("'", "'") + s = s.replace("<", "<") + s = s = s.replace(">", ">") + return hmod.unescape(s) + + +# ─── Основной парсер одной страницы ────────────────────────────────────────── + +def parse_page(html_content: str, page_index: int) -> dict | None: + """ + Парсит одну HTML-страницу и возвращает dict с полями: + title, breadcrumbs, body_lines (список строк Markdown), + images (list of data-uri), href_base (filename без .html). + Возвращает None, если страница не содержит полезного контента. + """ + # Извлекаем заголовок и хлебные крошки + title, breadcrumbs = extract_title_and_breadcrumbs(html_content) + + # Извлекаем тело топика + extractor = TopicExtractor() + extractor.feed(html_content) + raw_text = "".join(extractor.out) + + # Очищаем текст + body_lines = clean_text(raw_text) + + # Если нет полезного контента — пропускаем + if not body_lines or len(body_lines) <= 2: + return None + + # Извлекаем изображения + images = extract_images(html_content) + + # Преобразуем ссылки + body_lines = convert_links(body_lines, page_index) + + # Определяем basename для якорей + href_base_match = re.search(r' list[dict]: + """Строит индекс из zoom_pageinfo.js.""" + if not INDEX_PATH.exists(): + print("Индекс отсутствует — строю...", file=sys.stderr) + # Парсим zoom_pageinfo.js напрямую + pageinfo_path = DOC_DIR / "zoom_pageinfo.js" + with open(pageinfo_path, "r", encoding="utf-8") as f: + text = f.read() + + marker = "pagedata = " + pos = text.find(marker) + if pos < 0: + sys.exit(f"Не найден pagedata в {pageinfo_path}") + payload = text[pos + len(marker):].strip().rstrip(";").strip() + data = json.loads(payload) + + rows = [] + for entry in data: + if not entry or not entry[0] or entry[0] == 0: + continue + fname = entry[0].lstrip("./").strip() + title = hmod.unescape((entry[1] or "").strip()) + desc = hmod.unescape((entry[2] or "").strip())[:300] + rows.append({"file": fname, "title": title, "desc": desc}) + + with open(INDEX_PATH, "w", encoding="utf-8", newline="\n") as f: + for r in rows: + f.write(f"{r['file']}\t{r['title']}\t{r['desc']}\n") + + # Читаем индекс + pages = [] + with open(INDEX_PATH, "r", encoding="utf-8") as f: + for line in f: + parts = line.rstrip("\n").split("\t") + if len(parts) >= 1: + pages.append({"file": parts[0], "title": parts[1] if len(parts) > 1 else "", + "desc": parts[2] if len(parts) > 2 else ""}) + return pages + + +def main(): + print(f"Каталог справки: {DOC_DIR}") + print(f"Выходной файл: {OUTPUT_MD}") + + # Строим индекс если нужно + pages = build_index() + print(f"Страниц в индексе: {len(pages)}") + + # Заголовок документа + md_parts = [] + md_parts.append("# KOMPAS SDK — Справочная система КОМПАС-3D\n") + md_parts.append(f"*Автоматически экспортировано из Help & Manual WebHelp ({len(pages)} страниц).*\n") + + # Оглавление (только страницы с заголовками) + toc_entries = [] + processed_count = 0 + skipped_count = 0 + image_count = 0 + + for i, page_info in enumerate(pages): + fname = page_info["file"] + fpath = DOC_DIR / fname + + if not fpath.exists(): + skipped_count += 1 + continue + + try: + html_content = fpath.read_text(encoding="utf-8-sig") + except Exception as e: + print(f" ⚠ Не удалось прочитать {fname}: {e}", file=sys.stderr) + skipped_count += 1 + continue + + parsed = parse_page(html_content, i) + if parsed is None: + skipped_count += 1 + continue + + processed_count += 1 + href_base = parsed["href_base"] or f"page-{i}" + + # Собираем заголовок для TOC + title = parsed["title"] or fname.replace(".html", "").replace("_", " ") + toc_entries.append(f" - [{title}](#{href_base})") + + # Разделитель страницы + md_parts.append(f"\n---\n\n") + md_parts.append(f"## {title}\n") + md_parts.append(f"\n") + + # Хлебные крошки + if parsed["breadcrumbs"]: + bc_text = " > ".join(t for t, _ in parsed["breadcrumbs"]) + md_parts.append(f"*{bc_text}*\n") + + # Изображения (вставляются перед телом или в нужных местах) + if parsed["images"]: + for data_uri, alt in parsed["images"]: + alt_clean = alt.replace('"', "'").replace("'", "\\'") + md_parts.append(f"![{alt_clean}]({data_uri})\n") + image_count += 1 + + # Тело страницы + for line in parsed["body_lines"]: + line_unescaped = unescape_html(line) + # Убираем лишние пробелы, но сохраняем структуру + md_parts.append(f"{line_unescaped}\n") + + # Добавляем оглавление в начало (после заголовка) + final_md = md_parts[:2] # Заголовок + подзаголовок + final_md.append("## Оглавление\n") + final_md.extend(toc_entries) + final_md.extend(md_parts[2:]) + + # Записываем файл + output_text = "\n".join(final_md) + OUTPUT_MD.write_text(output_text, encoding="utf-8") + + print(f"\n✅ Готово!") + print(f" Обработано страниц: {processed_count}") + print(f" Пропущено (пустые/ошибки): {skipped_count}") + print(f" Сохранено изображений: {image_count}") + file_size = OUTPUT_MD.stat().st_size + print(f" Размер файла: {file_size / 1024 / 1024:.1f} MB") + + +if __name__ == "__main__": + main() diff --git a/tools/convert_sdk_to_md.py b/tools/convert_sdk_to_md.py new file mode 100644 index 0000000..e5dc559 --- /dev/null +++ b/tools/convert_sdk_to_md.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +convert_sdk_to_md.py — Экспорт справки KOMPAS SDK (Help & Manual WebHelp) в +отдельные Markdown-файлы. + +Каждая HTML-страница из docs/KOMPAS_SDK_ru-RU/ конвертируется в отдельный +.md файл в docs/KOMPAS_SDK_ru-RU/md/. Информативные изображения сохраняются +как base64 data-URI прямо в Markdown (inline). + +Изображения, которые НЕ являются информативными (UI, навигация и т.п.), пропускаются. +""" +import os +import sys +import re +import json +import base64 +import html as hmod +from html.parser import HTMLParser +from pathlib import Path + +# ─── Настройки ─────────────────────────────────────────────────────────────── +for _s in (sys.stdout, sys.stderr): + try: + _s.reconfigure(encoding="utf-8") + except Exception: + pass + +HERE = Path(__file__).resolve().parent.parent +DOC_DIR = HERE / "docs" / "KOMPAS_SDK_ru-RU" +INDEX_PATH = HERE / "docs" / "kompas_sdk_index.tsv" +OUTPUT_DIR = DOC_DIR / "md" # docs/KOMPAS_SDK_ru-RU/md/ + +# Изображения, которые НЕ являются информативными (UI-элементы навигации и т.п.) +UI_IMAGE_PREFIXES = [ + "leftright", "toc", "mob_", "TextPlus", "TextMinus", "TextNormal", + "ZoomIn", "ZoomClose", "spacer.gif", "bg.png", "page-bg.png", + "blackpaisley.jpeg", "banner_company", "kompas_logo_circle", + "kompas-logo", "internet.svg", "icon-new-window", "icon-open-file", + "warn", "note", "closehelp", "cicon_loadindex_ani", "kw-page", + "social_mail", "_a", "_praezession", "kbd.png", "page-icon", + "logo-image-desk", "logo-image-mobile", "close_data", "open_data", +] + + +# ─── Проверка: информативное ли изображение ────────────────────────────────── + +def is_informative_image(src: str) -> bool: + basename = os.path.basename(src).lower() + for prefix in UI_IMAGE_PREFIXES: + if prefix.lower() in basename: + return False + img_path = DOC_DIR / src + if img_path.exists() and img_path.stat().st_size < 500: + return False + return True + + +# ─── Парсер тела топика ───────────────────────────────────────────────────── + +class TopicExtractor(HTMLParser): + BLOCK_TAGS = {"p", "div", "br", "tr", "li", "h1", "h2", "h3", "h4", + "table", "pre", "td", "th"} + + def __init__(self): + super().__init__(convert_charrefs=True) + self.depth = 0 + self.capturing = False + self.skip = 0 + self.out = [] + + def handle_starttag(self, tag, attrs): + attrd = dict(attrs) + if not self.capturing and attrd.get("id") == "topicbody": + self.capturing = True + self.depth = 1 + return + if not self.capturing: + return + if tag in ("script", "style"): + self.skip += 1 + if tag in self.BLOCK_TAGS: + self.out.append("\n") + if tag != "br": + self.depth += 1 + + def handle_endtag(self, tag): + if not self.capturing: + return + if tag in ("script", "style") and self.skip: + self.skip -= 1 + if tag != "br": + self.depth -= 1 + if self.depth <= 0: + self.capturing = False + + def handle_data(self, data): + if self.capturing and not self.skip: + self.out.append(data) + + +# ─── Шум и подвал ──────────────────────────────────────────────────────────── + +NOISE_WORDS = { + "please enable javascript to view this site.", + "scroll", "предыдущий", "вверх", "следующий", + "содержание", "содержане", "указатель", "поиск", + "подразделы:", "(отсутствуют)", "поделиться", +} +FOOTER_RE = re.compile(r"©?\s*ООО\s*«?АСКОН") + + +def clean_text(raw: str) -> list[str]: + cleaned = [] + for line in raw.splitlines(): + s = re.sub(r"[ \t]+", " ", line).strip() + if not s: + continue + if s.lower() in NOISE_WORDS: + continue + if FOOTER_RE.search(s): + break + cleaned.append(s) + return cleaned + + +# ─── Извлечение изображений как base64 data-URI ────────────────────────────── + +def extract_images(html_content: str) -> list[tuple[str, str]]: + images = [] + for m in re.finditer(r']+>', html_content): + tag = m.group() + src_m = re.search(r'''src=["']([^"']+)["']''', tag) + alt_m = re.search(r'''alt=["']([^"']*)["']''', tag) + if not src_m: + continue + src = src_m.group(1) + alt = alt_m.group(1) if alt_m else "" + + if not is_informative_image(src): + continue + + img_path = DOC_DIR / src + if not img_path.exists(): + continue + + ext = os.path.splitext(src)[1].lower().lstrip(".") + mime_map = { + "jpg": "image/jpeg", "jpeg": "image/jpeg", + "png": "image/png", "gif": "image/gif", + "svg": "image/svg+xml", "webp": "image/webp", + "bmp": "image/bmp", + } + mime = mime_map.get(ext, f"image/{ext}") + + data = base64.b64encode(img_path.read_bytes()).decode("ascii") + data_uri = f"data:{mime};base64,{data}" + images.append((data_uri, alt)) + + return images + + +# ─── Заголовок и хлебные крошки ────────────────────────────────────────────── + +def extract_title_and_breadcrumbs(html_content: str) -> tuple[str, list[tuple[str, str]]]: + title = "" + for m in re.finditer(r'(.*?)', html_content): + title = hmod.unescape(m.group(1)).strip() + break + if not title: + for m in re.finditer(r']*>(.*?)

', html_content, re.DOTALL): + title = re.sub(r'<[^>]+>', '', m.group(1)).strip() + title = hmod.unescape(title) + break + + breadcrumbs = [] + bc_match = re.search( + r']*>(.*?)

', + html_content, re.DOTALL, + ) + if bc_match: + bc_html = bc_match.group(1) + for a_m in re.finditer(r']+href="([^"]*)"[^>]*>(.*?)', bc_html, re.DOTALL): + href = a_m.group(1) + text = re.sub(r'<[^>]+>', '', a_m.group(2)).strip() + text = hmod.unescape(text) + if text: + breadcrumbs.append((text, os.path.basename(href))) + + return title, breadcrumbs + + +# ─── Раскрытие HTML-сущностей ──────────────────────────────────────────────── + +def unescape_html(s: str) -> str: + s = s.replace(" ", " ") + s = s.replace(" ", " ") + s = s.replace("­", "") + s = s.replace("—", "—") + s = s.replace("–", "–") + s = s.replace("«", "«") + s = s.replace("»", "»") + s = s.replace(""", '"') + s = s.replace("'", "'") + s = s.replace("'", "'") + s = s.replace("<", "<") + s = s.replace(">", ">") + return hmod.unescape(s) + + +# ─── Извлечение ссылок из HTML-тела топика ────────────────────────────────── + +def extract_links_from_html(html_content: str) -> list[tuple[str, str]]: + """ + Находит все в topicbody и возвращает список + [(anchor_text, href), ...] для последующей подстановки. + anchor_text — текст ссылки без HTML-тегов. + """ + # Ищем всё содержимое после id="topicbody" до
+ body_match = re.search( + r'id="topicbody">(.*?)(?:)', + html_content, re.DOTALL) + if not body_match: + return [] + body_html = body_match.group(1) + + links = [] + for a_m in re.finditer(r']+href=["\']([^"\']+)["\'][^>]*>(.*?)', body_html, re.DOTALL): + href = a_m.group(1) + if not href.endswith('.html'): + continue + text = re.sub(r'<[^>]+>', '', a_m.group(2)).strip() + text = hmod.unescape(text) + if text: + links.append((text, href)) + return links + + +# ─── Преобразование ссылок в тексте Markdown ──────────────────────────────── + +def convert_links_in_text(text: str, links: list[tuple[str, str]]) -> str: + """ + Заменяет [текст](file.html) на [текст](./file.md). + Также заменяет plain-text ссылки (просто текст, совпадающий с anchor). + """ + def replace_link(m): + link_text = m.group(1) + href = m.group(2) + fname = os.path.basename(href).replace(".html", ".md") + return f"[{link_text}](./{fname})" + + text = re.sub(r'\[([^\]]+)\]\(([^)]+\.html[^)]*)\)', replace_link, text) + + # Заменяем "текст (file.html)" → "[текст](./file.md)" + for link_text, href in links: + fname = os.path.basename(href).replace(".html", ".md") + old = f"{link_text} ({href})" + new = f"[{link_text}](./{fname})" + if old in text: + text = text.replace(old, new) + + # Заменяем plain-text ссылки: текст, стоящий рядом со ссылкой, + # но без скобок — просто "Текст" на строке, где есть иная ссылка. + # Это покрывает случаи вида "...свойству IApplication::Visible значение TRUE." + # где IApplication::Visible — ссылка. + for link_text, href in links: + fname = os.path.basename(href).replace(".html", ".md") + try: + # Ищем точное совпадение текста ссылки (не внутри [[...]] или [..](..)) + # Пропускаем, если текст уже в Markdown-формате [text](url) + pattern = r'(? dict | None: + title, breadcrumbs = extract_title_and_breadcrumbs(html_content) + + # Извлекаем ссылки из HTML ДО очистки от тегов + links = extract_links_from_html(html_content) + + extractor = TopicExtractor() + extractor.feed(html_content) + raw_text = "".join(extractor.out) + + body_lines = clean_text(raw_text) + if not body_lines or len(body_lines) <= 2: + return None + + images = extract_images(html_content) + + # Определяем basename для имени выходного файла + href_base_match = re.search(r' list[dict]: + if INDEX_PATH.exists(): + pages = [] + with open(INDEX_PATH, "r", encoding="utf-8") as f: + for line in f: + parts = line.rstrip("\n").split("\t") + if len(parts) >= 1: + pages.append({"file": parts[0], "title": parts[1] if len(parts) > 1 else "", + "desc": parts[2] if len(parts) > 2 else ""}) + return pages + + pageinfo_path = DOC_DIR / "zoom_pageinfo.js" + with open(pageinfo_path, "r", encoding="utf-8") as f: + text = f.read() + + marker = "pagedata = " + pos = text.find(marker) + if pos < 0: + sys.exit(f"Не найден pagedata в {pageinfo_path}") + payload = text[pos + len(marker):].strip().rstrip(";").strip() + data = json.loads(payload) + + rows = [] + for entry in data: + if not entry or not entry[0] or entry[0] == 0: + continue + fname = entry[0].lstrip("./").strip() + title = hmod.unescape((entry[1] or "").strip()) + desc = hmod.unescape((entry[2] or "").strip())[:300] + rows.append({"file": fname, "title": title, "desc": desc}) + + with open(INDEX_PATH, "w", encoding="utf-8", newline="\n") as f: + for r in rows: + f.write(f"{r['file']}\t{r['title']}\t{r['desc']}\n") + + return rows + + +# ─── Генерация YAML-фронтматтера ───────────────────────────────────────────── + +def frontmatter(title: str, breadcrumbs: list[tuple[str, str]], + source_file: str) -> str: + lines = [f"---", f"title: \"{title}\"", f"source: {source_file}"] + if breadcrumbs: + lines.append(f"breadcrumbs:") + for text, fname in breadcrumbs: + lines.append(f' - text: "{text}"') + lines.append(f" file: {fname}") + lines.append("---") + return "\n".join(lines) + + +# ─── Основная функция ──────────────────────────────────────────────────────── + +def main(): + print(f"Справка: {DOC_DIR}") + print(f"Выходной каталог: {OUTPUT_DIR}") + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + pages = build_index() + print(f"Страниц в индексе: {len(pages)}") + + processed = 0 + skipped = 0 + total_images = 0 + errors = 0 + pages_with_links = 0 + + for i, page_info in enumerate(pages): + fname = page_info["file"] + fpath = DOC_DIR / fname + out_path = OUTPUT_DIR / fname.replace(".html", ".md") + + if not fpath.exists(): + skipped += 1 + continue + + try: + html_content = fpath.read_text(encoding="utf-8-sig") + except Exception as e: + print(f" ⚠ Ошибка чтения {fname}: {e}", file=sys.stderr) + errors += 1 + skipped += 1 + continue + + try: + parsed = parse_page(html_content) + except Exception as e: + print(f" ⚠ Parse error {fname}: {e}", file=sys.stderr) + errors += 1 + skipped += 1 + continue + + if parsed is None: + skipped += 1 + continue + + if parsed["links"]: + pages_with_links += 1 + + # Генерируем MD-контент + md_parts = [] + md_parts.append(frontmatter( + parsed["title"], parsed["breadcrumbs"], fname)) + md_parts.append("") + + # Хлебные крошки (как текст) + if parsed["breadcrumbs"]: + bc_text = " > ".join(t for t, _ in parsed["breadcrumbs"]) + md_parts.append(f"*{bc_text}*") + md_parts.append("") + + # Изображения + if parsed["images"]: + for data_uri, alt in parsed["images"]: + alt_clean = alt.replace('"', "'").replace("'", "\\'") + md_parts.append(f"![{alt_clean}]({data_uri})") + md_parts.append("") + total_images += 1 + + # Тело страницы + md_parts.extend(parsed["body_lines"]) + md_parts.append("") # trailing newline + + output_text = "\n".join(md_parts) + out_path.write_text(output_text, encoding="utf-8") + + processed += 1 + + print(f"\nDone!") + print(f" Processed pages: {processed}") + print(f" Skipped (empty/errors): {skipped}") + print(f" Read errors: {errors}") + print(f" Images saved: {total_images}") + print(f" Pages with links: {pages_with_links}") + + +if __name__ == "__main__": + main()