feat(inspection): структурное «зрение» модели — осмотр без снимка
Новый слой «модель как данные» (приоритетный над model_snapshot): - describe_model — структурный «паспорт» детали одним вызовом (габарит, МЦХ, тела, сводка топологии, дерево построения с параметрами, переменные) - list_features / list_bodies / list_variables - describe_face / describe_edge (drill-down по индексу) - measure (расстояние/угол между гранями/рёбрами/вершинами) - model_snapshot понижен до fallback для визуально-пространственных вопросов Реализация: ModelInspectionService (COM) + чистый InspectionText (рендер, группировка, детект «импорт без истории») + record-модели; InspectionTools. Дерево читается через ksPart.GetFeature()->SubFeatureCollection; точный тип и параметры операций берутся из GetObject()->GetDefinition() (ksFeature.type отдаёт лишь coarse o3d_entity). Снимок дерева также включает слои conversion (STEP), editing (move_face/solid) и validation, развивавшиеся параллельно. Полный прогон: 61 тест зелёный, 43 MCP-инструмента. Документация (README, ARCHITECTURE, OPEN_QUESTIONS, CLAUDE.md, presentation.html) синхронизирована. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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-приёмы импорта/экспорта/обхода).
|
||||||
@@ -31,14 +31,14 @@ The index lives at `docs/kompas_sdk_index.tsv` (`filename<TAB>title<TAB>excerpt`
|
|||||||
|
|
||||||
## Current state
|
## 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**.
|
**.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 +
|
**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, МЦХ).
|
||||||
sketch axis), fillet/chamfer (edge-by-point and by index), and Query tools `get_part_info` (МЦХ),
|
|
||||||
`get_bounding_box`, `list_faces`, `list_edges`.
|
|
||||||
See [`README.md`](README.md) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); design decisions/caveats in
|
See [`README.md`](README.md) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); design decisions/caveats in
|
||||||
[`docs/OPEN_QUESTIONS.md`](docs/OPEN_QUESTIONS.md).
|
[`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),
|
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
|
`tests/Kompas.Mcp.Tests` (unit + integration), `libs/kompas-interop/*.dll` (vendored КОМПАС interop
|
||||||
assemblies from SDK `Samples/Common`, referenced via `Directory.Build.props` → `KompasInteropDir`).
|
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.
|
- 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).
|
- **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, ...)`.
|
- **МЦХ / 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/`.
|
- 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)
|
## КОМПАС-3D API architecture (the critical context)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/Kompas.Mcp.ConnectSpike/Kompas.Mcp.ConnectSpike.csproj" />
|
|
||||||
<Project Path="src/Kompas.Mcp.Core/Kompas.Mcp.Core.csproj">
|
<Project Path="src/Kompas.Mcp.Core/Kompas.Mcp.Core.csproj">
|
||||||
<Platform Project="x64" />
|
<Platform Project="x64" />
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -4,7 +4,12 @@
|
|||||||
|
|
||||||
Сервер — это COM-клиент КОМПАС-3D: он подключается к установленному КОМПАС и предоставляет
|
Сервер — это COM-клиент КОМПАС-3D: он подключается к установленному КОМПАС и предоставляет
|
||||||
LLM набор инструментов для создания документов, построения 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/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)
|
> · спорные вопросы — [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` |
|
| System | `kompas_connect`, `kompas_status`, `kompas_set_visible` |
|
||||||
| Documents | `document_create`, `document_open`, `document_save`, `document_save_as`, `document_close`, `document_active` |
|
| 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` |
|
| Features | `extrude_boss`, `extrude_cut`, `revolve_boss`, `revolve_cut`, `fillet_edge`, `chamfer_edge`, `fillet_edge_index`, `chamfer_edge_index`, `rebuild` |
|
||||||
| Vision | `model_snapshot` (PNG-снимок активной модели) |
|
| Edit | `move_face` (прямое редактирование: сдвинуть грань на N мм вдоль нормали) |
|
||||||
| Query | `get_part_info`, `get_bounding_box`, `list_faces`, `list_edges` |
|
| 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` →
|
`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`, а сервер обрабатывает запросы конкурентно.
|
> сессия построения хранит эскизы по `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) + определения инструментов
|
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)
|
libs/kompas-interop/ вендорские interop-сборки КОМПАС (из SDK Samples/Common)
|
||||||
docs/ архитектура, план, презентация, навигатор по справке SDK
|
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.
|
- **STA-поток** (`KompasDispatcher`) владеет всеми COM-вызовами — КОМПАС однопоточный STA.
|
||||||
- Подключение через API5 `KompasObject` (`KOMPAS.Application.5`) → API7 `IApplication` (`ksGetApplication7`).
|
- Подключение через 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.
|
- MCP — официальный C# SDK `ModelContextProtocol`, транспорт stdio, логи в stderr.
|
||||||
|
|
||||||
## Лицензирование
|
## Лицензирование
|
||||||
|
|||||||
+54
-32
@@ -1,8 +1,7 @@
|
|||||||
# Архитектура MCP-сервера КОМПАС-3D (предлагаемый вариант)
|
# Архитектура MCP-сервера КОМПАС-3D (предлагаемый вариант)
|
||||||
|
|
||||||
> Статус: **черновик / предлагаемый вариант**, открыт к правкам.
|
> Статус: **реализовано и работает** (v1+v2+STEP/assembly+direct-edit+inspection). Актуализировано по коду.
|
||||||
> Назначение: зафиксировать выбор стека и целевую архитектуру решения до начала
|
> Сопутствующий контекст по COM API КОМПАС — в [`../CLAUDE.md`](../CLAUDE.md).
|
||||||
> реализации. Сопутствующий контекст по COM API КОМПАС — в [`../CLAUDE.md`](../CLAUDE.md).
|
|
||||||
> Имена интерфейсов и перечислений сверены со справкой SDK через `.claude/skills/kompas-sdk-research/kdoc.py` (навык `kompas-sdk-research`).
|
> Имена интерфейсов и перечислений сверены со справкой SDK через `.claude/skills/kompas-sdk-research/kdoc.py` (навык `kompas-sdk-research`).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -108,21 +107,21 @@ MCP client ──stdio──> Host (поток-пул, async)
|
|||||||
|
|
||||||
## 4. Слои решения
|
## 4. Слои решения
|
||||||
|
|
||||||
В v1 — логические слои (папки/проекты одного solution `KompasMcp.sln`):
|
Проекты solution `KompasMcp.slnx` (спайк-проекты ConnectSpike/ReconSpike удалены; их механика покрыта тестами):
|
||||||
|
|
||||||
| Слой | Ответственность |
|
| Проект | Ответственность |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Kompas.Mcp.Host** | Точка входа. Сборка MCP-сервера (stdio), DI, логи в stderr, старт и владение STA-потоком, graceful shutdown. |
|
| **Kompas.Mcp.Host** | Точка входа. Сборка MCP-сервера (stdio), DI (в т.ч. `ConversionService`), логи в stderr, старт и владение STA-потоком, graceful shutdown. Определения инструментов по категориям (System / Documents / Sketch / Features / Query / Conversion). **Тонкий слой**: валидация аргументов → постановка задачи на STA-поток → форматирование результата/ошибки в MCP-ответ. |
|
||||||
| **Kompas.Mcp.Tools** | Определения MCP-инструментов по категориям (System / Documents / Sketch / Features / Query). **Тонкий слой**: валидация аргументов → постановка задачи на STA-поток → форматирование результата/ошибки в MCP-ответ. |
|
| **Kompas.Mcp.Core** | COM-слой. Менеджер соединения, менеджер документов, построитель эскизов и операций, `ConversionService` (STEP импорт/экспорт), `QueryService` (МЦХ, грани, рёбра, компоненты), `ModelInspectionService` (дерево операций, тела, переменные, drill-down, измерения), снимок. Все паттерны API5/API7 живут здесь. |
|
||||||
| **Kompas.Mcp.Core** (`KompasSession`) | COM-слой. Менеджер соединения, менеджер документов, построитель эскизов, построитель операций, помощники выборки граней/плоскостей. Безопасные типизированные методы, null-проверки, трансляция ошибок. Здесь живут все паттерны работы с API5/API7. |
|
| **Kompas.Mcp.Tests** | Юнит (без COM) + интеграционные (требуют КОМПАС). 60 тестов. |
|
||||||
| **Kompas.Mcp.Interop** | Interop-сборки из `.tlb` + маппинг «дружелюбная строка ↔ перечисление» (`DocumentTypeEnum`, `Obj3dType`, `ksEndTypeEnum`, `ksDirectionTypeEnum`). |
|
|
||||||
|
|
||||||
Принцип: **только `Core` и `Interop` знают про COM**. `Tools` оперируют доменными
|
Принцип: **только `Core` знает про COM**. `Host`/Tools оперируют доменными DTO и вызывают `Core`; `Host` не содержит бизнес-логики.
|
||||||
DTO и вызывают `Core`; `Host` не содержит бизнес-логики.
|
|
||||||
|
Архитектурный принцип: **MCP транслирует возможности SDK КОМПАС в общие инструменты** (не под конкретную задачу). Методический слой поверх MCP — навык `.claude/skills/kompas-3d/` (playbook'и, эвристики). Полигон отработки подходов — `usecases/` (в .gitignore).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Карта инструментов v1
|
## 5. Карта инструментов (43 инструмента)
|
||||||
|
|
||||||
Сгруппированы вокруг центрального цикла «эскиз ↔ операция». Имена — `snake_case`.
|
Сгруппированы вокруг центрального цикла «эскиз ↔ операция». Имена — `snake_case`.
|
||||||
|
|
||||||
@@ -147,14 +146,37 @@ DTO и вызывают `Core`; `Host` не содержит бизнес-лог
|
|||||||
- `revolve_boss` / `revolve_cut` — угол.
|
- `revolve_boss` / `revolve_cut` — угол.
|
||||||
- `rebuild` — перестроить деталь.
|
- `rebuild` — перестроить деталь.
|
||||||
|
|
||||||
**Selection / Query**
|
**Edit** (прямое редактирование)
|
||||||
- `select_face_by_point` — выбрать грань точкой (для следующего эскиза).
|
- `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 мм.
|
||||||
- `list_bodies`, `list_faces_planes` — перечислить тела/грани и плоскости.
|
|
||||||
- `get_part_info` — МЦХ/габариты (`IMassInertiaParam7`).
|
|
||||||
|
|
||||||
**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**,
|
- `model_snapshot` — отрендерить активный 3D-документ в PNG и вернуть **как image-контент MCP**,
|
||||||
чтобы мультимодальный агент *увидел* промежуточный результат и контролировал построение пошагово.
|
чтобы мультимодальный агент *увидел* промежуточный результат. Использовать для визуально-пространственных
|
||||||
|
вопросов, когда структурный осмотр (`describe_model`) недостаточен.
|
||||||
- `set_view` — задать ориентацию вида (`iso` / `front` / `top` / `right` / …), zoom-to-fit и режим
|
- `set_view` — задать ориентацию вида (`iso` / `front` / `top` / `right` / …), zoom-to-fit и режим
|
||||||
отображения (`ShadedWireframe`) перед снимком — иначе снимок берётся со случайного текущего ракурса.
|
отображения (`ShadedWireframe`) перед снимком — иначе снимок берётся со случайного текущего ракурса.
|
||||||
|
|
||||||
@@ -282,24 +304,24 @@ dotnet build -c Release -r win-x64 # сборка
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Дорожная карта (после v1)
|
## 10. Дорожная карта
|
||||||
|
|
||||||
1. Операции: `fillet` / `chamfer` / `shell` / `rib` / `loft` / `sweep`; массивы
|
**Реализовано (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`** (структурный осмотр модели).
|
||||||
(`ILinearPattern`, `ICircularPattern`).
|
|
||||||
2. Параметры и свойства: `IVariable7` / `IVariableTable`, `IPropertyMng` / `IPropertyKeeper`.
|
**Следующие приоритеты:**
|
||||||
3. Полноценное 2D-черчение: виды, линии/дуги/окружности, размеры (`ILinearDimension`, …),
|
1. Рассечение/перемещение тела как MCP-инструменты (`SplitSolids`/`BodyRepositions`) — механика есть, продуктизация не закончена.
|
||||||
штриховки, тексты.
|
2. Массивы (`ILinearPattern`, `ICircularPattern`).
|
||||||
4. Спецификации (`ISpecification`), сборки и сопряжения (`IAssemblyDocument`).
|
3. Свойства документа: `IPropertyMng` / `IPropertyKeeper`.
|
||||||
5. Конвертеры/экспорт (STEP и др.) через `IApplication.Converter`.
|
4. Полноценное 2D-черчение: виды, линии/дуги/окружности, размеры (`ILinearDimension`, …), штриховки, тексты.
|
||||||
|
5. Спецификации (`ISpecification`), построение сборок и сопряжения.
|
||||||
6. Транспорт **HTTP/SSE** — как опция для удалённых клиентов.
|
6. Транспорт **HTTP/SSE** — как опция для удалённых клиентов.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Открытые вопросы
|
## 11. Открытые вопросы
|
||||||
|
|
||||||
- **Один проект vs solution из 4 проектов** в v1 (склоняемся к одному solution, слои — папками,
|
- **Прямое редактирование B-rep, перемещение грани**: решено — `move_face` (`FaceEditService`) работает через `IPart7.FindObjectsByPoint` + `FaceMover`; проверено на импортированной B-rep. Рассечение/перемещение тела (`SplitSolids`/`BodyRepositions`) — механика есть, инструменты не реализованы (потенциальная следующая опция).
|
||||||
выделять проекты по мере роста).
|
- **Дерево операций, тела, переменные, геометрические запросы**: решено — `ModelInspectionService` + `InspectionTools` (7 инструментов); юнит-тестируемый рендер через `InspectionText`.
|
||||||
- **Embed Interop Types vs генерируемые interop-сборки** (`tlbimp`) — проверить на реальных
|
- **Утечки транзитных RCW** в `PartModeler` (унаследованный паттерн): `EntityCollection`, `GetTopPart()` не освобождаются, накапливаются за длинную сессию.
|
||||||
`.tlb` v24, выбрать по совместимости типов и размеру.
|
- **Насос сообщений / CancellationToken**: `CancellationToken` не прерывает идущий COM-вызов; зависший модальный диалог КОМПАС блокирует всю очередь.
|
||||||
- **Стратегия именования инструментов** — подтвердить `snake_case` с группами-префиксами
|
- **Embed Interop Types vs вендорские interop-сборки**: выбраны вендорские DLL из SDK `Samples/Common`; менять не планируется.
|
||||||
(`document_*`, `sketch_*`, …).
|
|
||||||
|
|||||||
@@ -112,3 +112,32 @@ stateful-сессии? Пока полагаемся на последовате
|
|||||||
`SnapshotService` рендерит во временный файл и читает байты обратно (надёжно проверено на v24 Home).
|
`SnapshotService` рендерит во временный файл и читает байты обратно (надёжно проверено на v24 Home).
|
||||||
**На проработку:** пустое имя файла + `returnResultAsArrayBytes=true` для чистого in-memory —
|
**На проработку:** пустое имя файла + `returnResultAsArrayBytes=true` для чистого in-memory —
|
||||||
не проверено; текущий путь через temp-файл работает.
|
не проверено; текущий путь через 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 тестов.**
|
||||||
|
|||||||
+44
-28
@@ -125,7 +125,7 @@
|
|||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<span class="badge"><span class="dot" style="background:var(--green);box-shadow:0 0 10px var(--green)"></span> Статус: v2 работает — цикл эскиз→операции→снимок через MCP (31 инструмент)</span>
|
<span class="badge"><span class="dot" style="background:var(--green);box-shadow:0 0 10px var(--green)"></span> Статус: v3 работает — эскиз→операции→STEP→сборка→move_face→структурный осмотр (43 инструмента)</span>
|
||||||
<h1>КОМПАС-3D <span class="g">MCP-сервер</span><br>управление CAD языком LLM</h1>
|
<h1>КОМПАС-3D <span class="g">MCP-сервер</span><br>управление CAD языком LLM</h1>
|
||||||
<p class="lead">MCP-сервер, который превращает операции КОМПАС-3D — создание документов,
|
<p class="lead">MCP-сервер, который превращает операции КОМПАС-3D — создание документов,
|
||||||
эскизы, 3D-операции, параметры — в инструменты для языковой модели. Под капотом —
|
эскизы, 3D-операции, параметры — в инструменты для языковой модели. Под капотом —
|
||||||
@@ -173,24 +173,36 @@
|
|||||||
<p>Выдавливание и вырез, вращение, перестроение. Управление направлением, глубиной и
|
<p>Выдавливание и вырез, вращение, перестроение. Управление направлением, глубиной и
|
||||||
типом окончания (<code>ksEndTypeEnum</code>).</p>
|
типом окончания (<code>ksEndTypeEnum</code>).</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="ic">🔍</div>
|
||||||
|
<h3>Структурный осмотр</h3>
|
||||||
|
<p><code>describe_model</code> — паспорт детали (МЦХ, тела, дерево, переменные) одним вызовом.
|
||||||
|
<code>describe_face</code> · <code>describe_edge</code> · <code>measure</code>.
|
||||||
|
Предпочтительнее снимка — без расхода токенов изображения.</p>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="ic">🎯</div>
|
<div class="ic">🎯</div>
|
||||||
<h3>Выборка и запросы</h3>
|
<h3>Выборка и запросы</h3>
|
||||||
<p>Выбор грани точкой для следующего эскиза, перечисление тел и граней,
|
<p>Перечисление граней, рёбер и компонентов сборки с классификацией; МЦХ, габарит.
|
||||||
массово-центровочные характеристики детали.</p>
|
<code>list_faces</code> · <code>list_edges</code> · <code>list_components</code>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="ic">📦</div>
|
||||||
|
<h3>STEP импорт / экспорт</h3>
|
||||||
|
<p>Импорт .step/.stp в новый документ (деталь или сборка, создание файлов компонентов);
|
||||||
|
экспорт в AP203/AP214/AP242 через встроенный конвертер КОМПАС.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="ic">✂️</div>
|
||||||
|
<h3>Прямое редактирование</h3>
|
||||||
|
<p><code>move_face</code>: сдвинуть грань на N мм вдоль нормали (наружу или внутрь).
|
||||||
|
Работает на импортированной B-rep через <code>FaceMover</code> API7.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="ic">👁️</div>
|
<div class="ic">👁️</div>
|
||||||
<h3>Зрение агента</h3>
|
<h3>Зрение агента</h3>
|
||||||
<p>Рендер модели в PNG прямо в память (<code>resultArrayBytes</code>) и возврат как
|
<p>Рендер модели в PNG через временный файл и возврат как image-контент MCP — агент <b>видит</b>
|
||||||
image-контент MCP — агент <b>видит</b> результат и строит пошагово. Плюс <code>set_view</code>:
|
результат. Fallback после <code>describe_model</code> для визуально-пространственных вопросов.</p>
|
||||||
ориентация и zoom-to-fit перед снимком.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="ic">🛟</div>
|
|
||||||
<h3>Escape-hatch</h3>
|
|
||||||
<p>Прямой вызов команд КОМПАС через <code>ExecuteKompasCommand</code> для того,
|
|
||||||
что ещё не обёрнуто в типизированные инструменты.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,8 +258,8 @@
|
|||||||
впереди.</p>
|
впереди.</p>
|
||||||
|
|
||||||
<div class="prog">
|
<div class="prog">
|
||||||
<div class="progrow"><span>Общий прогресс</span><span>v2: грань · вращение · скругление/фаска · габариты · список граней/рёбер · 19 тестов</span></div>
|
<div class="progrow"><span>Общий прогресс</span><span>v3: STEP · list_components · move_face · структурный осмотр · навык kompas-3d · 43 инструмента · 60 тестов</span></div>
|
||||||
<div class="bar"><i style="width:84%"></i></div>
|
<div class="bar"><i style="width:93%"></i></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="todo-grid" style="margin-top:26px">
|
<div class="todo-grid" style="margin-top:26px">
|
||||||
@@ -281,16 +293,19 @@
|
|||||||
|
|
||||||
<!-- TOOLS -->
|
<!-- TOOLS -->
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>Инструменты v1 <span class="tag done">ГОТОВО</span></h3>
|
<h3>Инструменты v3 <span class="tag done">ГОТОВО</span></h3>
|
||||||
<p class="meta">31 инструмент, отдаются по MCP-протоколу</p>
|
<p class="meta">43 инструмента, отдаются по MCP-протоколу</p>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>System</code>: connect · status · set_visible</span></li>
|
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>System</code>: connect · status · set_visible</span></li>
|
||||||
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Documents</code>: create · open · save · save_as · close · active</span></li>
|
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Documents</code>: create · open · save · save_as · close · active</span></li>
|
||||||
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Sketch</code>: create · on_face (точка/индекс) · add line/circle/rectangle/axis · close</span></li>
|
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Sketch</code>: create · on_face (точка/индекс) · add line/circle/rectangle/axis · close</span></li>
|
||||||
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Features</code>: extrude · revolve · fillet_edge/chamfer_edge (точка) · fillet_edge_index/chamfer_edge_index (индекс) 🆕 · rebuild</span></li>
|
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Features</code>: extrude · revolve · fillet_edge/chamfer_edge (точка + индекс) · rebuild</span></li>
|
||||||
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Vision</code>: model_snapshot 👁️</span></li>
|
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Edit</code>: move_face (прямое редактирование B-rep)</span></li>
|
||||||
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Query</code>: get_part_info · get_bounding_box · list_faces · list_edges 🆕</span></li>
|
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Inspection</code>: describe_model · list_features · list_bodies · list_variables · describe_face · describe_edge · measure 🆕</span></li>
|
||||||
<li><span class="mark todo"></span><span><code>Query</code>: list_bodies (далее)</span></li>
|
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Vision</code>: model_snapshot 👁️ (fallback)</span></li>
|
||||||
|
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Query</code>: get_part_info · get_bounding_box · list_faces · list_edges · list_components</span></li>
|
||||||
|
<li class="muted"><span class="mark ok">✓</span><span class="t"><code>Conversion</code>: import_step · export_step</span></li>
|
||||||
|
<li><span class="mark todo"></span><span>Массивы/паттерны · рассечение/перемещение тела (далее)</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -299,10 +314,11 @@
|
|||||||
<h3>Качество и поставка <span class="tag now">В РАБОТЕ</span></h3>
|
<h3>Качество и поставка <span class="tag now">В РАБОТЕ</span></h3>
|
||||||
<p class="meta">Тесты, сборка, интеграция с клиентом</p>
|
<p class="meta">Тесты, сборка, интеграция с клиентом</p>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li class="muted"><span class="mark ok">✓</span><span class="t">Unit-тесты диспетчера (5) — без COM</span></li>
|
<li class="muted"><span class="mark ok">✓</span><span class="t">Unit-тесты — без COM: диспетчер, enum-маппинг, StepFormat, InspectionText</span></li>
|
||||||
<li class="muted"><span class="mark ok">✓</span><span class="t">Интеграционные тесты с КОМПАС (7): подключение, документы, снимок, цикл</span></li>
|
<li class="muted"><span class="mark ok">✓</span><span class="t">Интеграционные тесты с КОМПАС: подключение, документы, снимок, цикл, STEP round-trip, сборка, move_face, инспекция</span></li>
|
||||||
<li class="muted"><span class="mark ok">✓</span><span class="t">Сквозной прогон по MCP-протоколу (tools/call → PNG)</span></li>
|
<li class="muted"><span class="mark ok">✓</span><span class="t">Итого 60 тестов зелёных</span></li>
|
||||||
<li class="muted"><span class="mark ok">✓</span><span class="t">Сборка <code>kompas-mcp.exe</code>, README с конфигом клиента</span></li>
|
<li class="muted"><span class="mark ok">✓</span><span class="t">Сборка <code>kompas-mcp.exe</code>, README с конфигом клиента</span></li>
|
||||||
|
<li class="muted"><span class="mark ok">✓</span><span class="t">Навык <code>kompas-3d</code> (методика) + полигон <code>usecases/</code></span></li>
|
||||||
<li><span class="mark todo"></span><span>In-process тест MCP-клиента, релизный publish</span></li>
|
<li><span class="mark todo"></span><span>In-process тест MCP-клиента, релизный publish</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -318,11 +334,11 @@
|
|||||||
<p class="sub">Наращиваем покрытие API волнами — от простых операций к параметрике,
|
<p class="sub">Наращиваем покрытие API волнами — от простых операций к параметрике,
|
||||||
черчению и сборкам.</p>
|
черчению и сборкам.</p>
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
<div class="tl" style="border-color:var(--green)"><div class="ph" style="color:var(--green)">v1 · готово ✓</div><h4>Цикл моделирования</h4><p>Документы, эскизы, выдавливание/вырез, снимок модели — работает</p></div>
|
<div class="tl" style="border-color:var(--green)"><div class="ph" style="color:var(--green)">v1 · готово ✓</div><h4>Цикл моделирования</h4><p>Документы, эскизы, выдавливание/вырез, снимок модели</p></div>
|
||||||
<div class="tl cur"><div class="ph">v2 · в работе</div><h4>Операции и выборка</h4><p>Эскиз на грани ✓ · вращение ✓ · скругление/фаска (точка + индекс) ✓ · список граней/рёбер ✓ · далее: массивы · list_bodies</p></div>
|
<div class="tl" style="border-color:var(--green)"><div class="ph" style="color:var(--green)">v2 · готово ✓</div><h4>Операции, выборка, STEP</h4><p>Эскиз на грани · вращение · скругление/фаска · list_faces/edges · import_step · export_step · list_components · навык kompas-3d</p></div>
|
||||||
<div class="tl"><div class="ph">v3</div><h4>Параметрика</h4><p>Переменные <code>IVariable7</code>, свойства <code>IPropertyMng</code>, формулы</p></div>
|
<div class="tl" style="border-color:var(--green)"><div class="ph" style="color:var(--green)">v3 · готово ✓</div><h4>B-rep + структурный осмотр</h4><p><b>move_face</b> (сдвиг грани на N мм) · <b>describe_model</b> · list_features · list_bodies · list_variables · describe_face · describe_edge · measure</p></div>
|
||||||
<div class="tl"><div class="ph">v4</div><h4>2D-черчение</h4><p>Виды, размеры, тексты, штриховки; спецификации</p></div>
|
<div class="tl cur"><div class="ph">v4 · в работе</div><h4>Массивы + 2D</h4><p>Массивы/паттерны · рассечение тела · виды, размеры, штриховки; свойства документа</p></div>
|
||||||
<div class="tl"><div class="ph">v5</div><h4>Сборки и экспорт</h4><p>Компоненты, сопряжения, конвертеры (STEP), HTTP/SSE-транспорт</p></div>
|
<div class="tl"><div class="ph">v5</div><h4>Сборки и транспорт</h4><p>Построение сборок, сопряжения; HTTP/SSE-транспорт</p></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<AssemblyName>kompas-connect-spike</AssemblyName>
|
|
||||||
<RootNamespace>Kompas.Mcp.ConnectSpike</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<!-- Вендорские interop-сборки КОМПАС (из SDK Samples/Common). -->
|
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="KompasAPI7">
|
|
||||||
<HintPath>$(KompasInteropDir)\KompasAPI7.dll</HintPath>
|
|
||||||
<Private>true</Private>
|
|
||||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="Kompas6API5">
|
|
||||||
<HintPath>$(KompasInteropDir)\Kompas6API5.dll</HintPath>
|
|
||||||
<Private>true</Private>
|
|
||||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="Kompas6Constants">
|
|
||||||
<HintPath>$(KompasInteropDir)\Kompas6Constants.dll</HintPath>
|
|
||||||
<Private>true</Private>
|
|
||||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="Kompas6Constants3D">
|
|
||||||
<HintPath>$(KompasInteropDir)\Kompas6Constants3D.dll</HintPath>
|
|
||||||
<Private>true</Private>
|
|
||||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Привести COM-корень к IApplication: API7 — прямой каст, API5 — через ksGetApplication7().</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Импорт/экспорт обменных форматов (STEP) через КОМПАС-конвертер.
|
||||||
|
/// <para>
|
||||||
|
/// Встроенный конвертер получается через <c>IApplication.get_Converter(код_формата)</c>
|
||||||
|
/// (целочисленный код <c>ksKOMPASConverterEnum</c>, НЕ путь к DLL), параметры — через
|
||||||
|
/// <c>IConverter.ConverterParameters</c>, сама конвертация — через
|
||||||
|
/// <c>IKompasDocument3D1.ConvertTo/FromAdditionFormat</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Импортировать STEP-файл в новый документ заданного типа (по умолчанию — сборка) и сделать его активным.
|
||||||
|
/// <paramref name="createComponentFiles"/> — писать ли компоненты сборки отдельными .m3d рядом с STEP.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Экспортировать активный 3D-документ в STEP по указанному пути.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получить встроенный конвертер. Для STEP get_Converter принимает целочисленный код формата
|
||||||
|
/// (boxed int), НЕ путь к DLL. Пробуем код формата, затем базовый код экспорта STEP (3).
|
||||||
|
/// </summary>
|
||||||
|
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 { /* освобождение не критично */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Conversion;
|
||||||
|
|
||||||
|
/// <summary>Вариант стандарта STEP для экспорта.</summary>
|
||||||
|
public enum StepFormat
|
||||||
|
{
|
||||||
|
/// <summary>Формат по умолчанию (ksConverterToSTEP).</summary>
|
||||||
|
Auto,
|
||||||
|
Ap203,
|
||||||
|
Ap214,
|
||||||
|
Ap242,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Маппинг формата STEP в целочисленные коды конвертера КОМПАС (<c>ksKOMPASConverterEnum</c>).
|
||||||
|
/// Эти коды передаются в <c>IApplication.get_Converter</c> и <c>IAdditionConvertParameters.Format</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class StepFormats
|
||||||
|
{
|
||||||
|
/// <summary>Код импорта STEP — <c>ksConverterFromSTEP</c>.</summary>
|
||||||
|
public const int ImportConverterCode = -3;
|
||||||
|
|
||||||
|
/// <summary>Код экспорта STEP для выбранного стандарта (<c>ksConverterToSTEP[_APxxx]</c>).</summary>
|
||||||
|
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."),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Разобрать строку формата ("auto"/"step", "ap203", "ap214", "ap242").</summary>
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Editing;
|
||||||
|
|
||||||
|
/// <summary>Габарит тела (по индексу в наборе тел детали) — для выбора тела в операциях редактирования.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using Kompas.Mcp.Core.Threading;
|
||||||
|
using KompasAPI7;
|
||||||
|
|
||||||
|
namespace Kompas.Mcp.Core.Editing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Прямое редактирование тела: перемещение грани (КОМПАС «Переместить грань», <c>FaceMover</c>).
|
||||||
|
/// Работает и на импортированной «тупой» B-rep геометрии без дерева построения.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Переместить грань активной детали, найденную по мировой точке (<paramref name="x"/>,<paramref name="y"/>,<paramref name="z"/>),
|
||||||
|
/// на <paramref name="distance"/> мм вдоль её нормали. Направление подбирается автоматически
|
||||||
|
/// (наружу — добавление материала); если КОМПАС отвергает направление, пробуется обратное.
|
||||||
|
/// </summary>
|
||||||
|
public Task MoveFaceAsync(double x, double y, double z, double distance, CancellationToken ct = default)
|
||||||
|
=> _dispatcher.InvokeAsync(() => MoveFaceCore(x, y, z, distance), ct);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Операции над телами импортированной/построенной геометрии: рассечение плоскостью
|
||||||
|
/// (<c>SplitSolids</c>), перемещение тела (<c>BodyRepositions</c>), объединение (<c>Booleans</c>).
|
||||||
|
/// Контейнеры операций берутся приведением детали (COM-QI): <c>IModelContainer</c>/<c>IAuxiliaryGeomContainer</c>.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Рассечь тело активной детали смещённой плоскостью (база <paramref name="basePlane"/> + <paramref name="offset"/> мм).
|
||||||
|
/// Возвращает габариты получившихся тел (с индексами) — по ним выбирают тело для <see cref="MoveBodyAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public Task<IReadOnlyList<BodyBox>> SplitByPlaneAsync(
|
||||||
|
BasePlane basePlane, double offset, bool direction = true, CancellationToken ct = default)
|
||||||
|
=> _dispatcher.InvokeAsync<IReadOnlyList<BodyBox>>(() =>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>Переместить тело (по индексу из <see cref="SplitByPlaneAsync"/>) на вектор (dx, dy, dz) мм.</summary>
|
||||||
|
public Task MoveBodyAsync(int bodyIndex, double dx = 0, double dy = 0, double dz = 0, CancellationToken ct = default)
|
||||||
|
=> _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);
|
||||||
|
|
||||||
|
/// <summary>Объединить все тела активной детали в одно (булева операция «объединение»).</summary>
|
||||||
|
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<object>().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<IBody7> ResultBodies(IPart7 top)
|
||||||
|
{
|
||||||
|
var list = new List<IBody7>();
|
||||||
|
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<BodyBox> BodyBoxes(IPart7 top)
|
||||||
|
{
|
||||||
|
var bodies = ResultBodies(top);
|
||||||
|
var list = new List<BodyBox>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Query;
|
||||||
|
|
||||||
|
/// <summary>Сводка по телу детали.</summary>
|
||||||
|
public sealed record BodyInfo
|
||||||
|
{
|
||||||
|
/// <summary>Индекс тела в коллекции тел детали.</summary>
|
||||||
|
public required int Index { get; init; }
|
||||||
|
|
||||||
|
/// <summary>true — твёрдое (замкнутое) тело.</summary>
|
||||||
|
public required bool IsSolid { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Число граней тела.</summary>
|
||||||
|
public required int FaceCount { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Query;
|
||||||
|
|
||||||
|
/// <summary>Сводка по компоненту сборки (деталь или подсборка верхнего уровня).</summary>
|
||||||
|
public sealed record ComponentInfo
|
||||||
|
{
|
||||||
|
/// <summary>Индекс в коллекции компонентов верхнего уровня.</summary>
|
||||||
|
public required int Index { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Имя компонента в дереве.</summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Обозначение (marking), если задано.</summary>
|
||||||
|
public required string Marking { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Путь к файлу-источнику компонента, если есть.</summary>
|
||||||
|
public required string FileName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>true — деталь, false — подсборка.</summary>
|
||||||
|
public required bool IsDetail { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Габаритные размеры компонента (мм). 0, если габарит недоступен.</summary>
|
||||||
|
public required double SizeX { get; init; }
|
||||||
|
public required double SizeY { get; init; }
|
||||||
|
public required double SizeZ { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Query;
|
||||||
|
|
||||||
|
/// <summary>Детальные сведения о ребре (drill-down по индексу из list_edges).</summary>
|
||||||
|
public sealed record EdgeDetail
|
||||||
|
{
|
||||||
|
public required int Index { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Тип кривой: line, circle, arc, ellipse, ellipse_arc, nurbs, other.</summary>
|
||||||
|
public required string Type { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Длина ребра, мм.</summary>
|
||||||
|
public required double Length { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Тип одной смежной грани (по «плюс»-ориентации), либо null.</summary>
|
||||||
|
public string? AdjacentFace1 { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Тип другой смежной грани (по «минус»-ориентации), либо null.</summary>
|
||||||
|
public string? AdjacentFace2 { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Координаты начальной вершины (мм), либо null.</summary>
|
||||||
|
public (double X, double Y, double Z)? Start { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Координаты конечной вершины (мм), либо null.</summary>
|
||||||
|
public (double X, double Y, double Z)? End { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Query;
|
||||||
|
|
||||||
|
/// <summary>Детальные сведения о грани (drill-down по индексу из list_faces).</summary>
|
||||||
|
public sealed record FaceDetail
|
||||||
|
{
|
||||||
|
public required int Index { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Тип поверхности: plane, cylinder, cone, sphere, torus, nurbs, other.</summary>
|
||||||
|
public required string Type { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Площадь грани, мм².</summary>
|
||||||
|
public required double Area { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Единичная нормаль в центре грани (мировые оси), либо null если недоступна.</summary>
|
||||||
|
public (double X, double Y, double Z)? Normal { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Радиус цилиндрической грани, мм (null для нецилиндрических).</summary>
|
||||||
|
public double? Radius { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Число рёбер, ограничивающих грань.</summary>
|
||||||
|
public required int EdgeCount { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Query;
|
||||||
|
|
||||||
|
/// <summary>Узел дерева построения детали (операция или системный объект).</summary>
|
||||||
|
public sealed record FeatureInfo
|
||||||
|
{
|
||||||
|
/// <summary>Порядковый индекс в дереве построения.</summary>
|
||||||
|
public required int Index { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Имя узла в дереве (локализовано КОМПАС: «Элемент выдавливания:1», «Скругление:1»…).</summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Семантический вид операции, определённый по типу её определения:
|
||||||
|
/// origin, sketch, extrusion, cut_extrusion, revolve, cut_revolve, fillet, chamfer, body, other.
|
||||||
|
/// </summary>
|
||||||
|
public required string Kind { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Ключевые параметры операции для показа («глубина 15.00 мм», «R3.00 мм»), либо null.</summary>
|
||||||
|
public string? Detail { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Kompas.Mcp.Core.Query;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чистый (без COM) рендер структурного «паспорта» модели и вспомогательные форматтеры.
|
||||||
|
/// Вынесено отдельно, чтобы покрыть логику представления юнит-тестами.
|
||||||
|
/// </summary>
|
||||||
|
public static class InspectionText
|
||||||
|
{
|
||||||
|
// Габарит пустой детали КОМПАС отдаёт «пустышкой» ~2·10³⁰⁸ (double близко к максимуму).
|
||||||
|
private const double EmptyGabaritThreshold = 1e12;
|
||||||
|
|
||||||
|
/// <summary>true — габарит соответствует пустой модели (sentinel-«пустышка» или нулевой размер).</summary>
|
||||||
|
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" };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Модель — «голый» импорт без истории построения (типично для STEP): в дереве только начало
|
||||||
|
/// координат и тело(а), нет ни формообразующих операций, ни иных узлов-правок.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsImported(IReadOnlyList<FeatureInfo> features, int bodyCount)
|
||||||
|
=> bodyCount > 0
|
||||||
|
&& !features.Any(f => OpKinds.Contains(f.Kind))
|
||||||
|
&& features.Count <= bodyCount + 1; // origin + тело(а), ничего сверх
|
||||||
|
|
||||||
|
/// <summary>Сгруппировать типы и вернуть «3 plane, 2 cylinder» (по убыванию числа, тай-брейк по имени).</summary>
|
||||||
|
public static string GroupTypes(IReadOnlyList<string> 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}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Отрендерить «паспорт» модели в читаемый текст.</summary>
|
||||||
|
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<BodyInfo> 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<VariableInfo> 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Query;
|
||||||
|
|
||||||
|
/// <summary>Результат измерения между двумя объектами модели (грань/ребро/вершина).</summary>
|
||||||
|
public sealed record MeasureResult
|
||||||
|
{
|
||||||
|
/// <summary>Расстояние, мм (для совпадающих/пересекающихся объектов — 0).</summary>
|
||||||
|
public required double Distance { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Минимальное расстояние, мм.</summary>
|
||||||
|
public required double MinDistance { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Угол между объектами, градусы (валиден только когда <see cref="AngleValid"/>).</summary>
|
||||||
|
public required double Angle { get; init; }
|
||||||
|
|
||||||
|
/// <summary>true — угол применим к данной паре объектов.</summary>
|
||||||
|
public required bool AngleValid { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Query;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Структурный «паспорт» активной детали: всё, что нужно агенту, чтобы «увидеть» модель
|
||||||
|
/// без растрового снимка. Заполняется <c>ModelInspectionService</c>, рендерится <c>InspectionText</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ModelDescription
|
||||||
|
{
|
||||||
|
/// <summary>Имя документа/детали.</summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Модель пуста (нет тел; габарит-«пустышка»).</summary>
|
||||||
|
public required bool IsEmpty { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Габарит детали (null для пустой модели).</summary>
|
||||||
|
public BoundingBox? Box { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Массово-центровочные характеристики (null для пустой модели).</summary>
|
||||||
|
public PartInfo? Mass { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Тела детали.</summary>
|
||||||
|
public required IReadOnlyList<BodyInfo> Bodies { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Типы всех граней (для сводки по топологии).</summary>
|
||||||
|
public required IReadOnlyList<string> FaceTypes { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Типы всех рёбер (для сводки по топологии).</summary>
|
||||||
|
public required IReadOnlyList<string> EdgeTypes { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Узлы дерева построения по порядку.</summary>
|
||||||
|
public required IReadOnlyList<FeatureInfo> Features { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Модель импортирована (дерево содержит только начало координат и тело(а), без
|
||||||
|
/// параметрической истории) — типично для импорта STEP.
|
||||||
|
/// </summary>
|
||||||
|
public required bool Imported { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Переменные модели.</summary>
|
||||||
|
public required IReadOnlyList<VariableInfo> Variables { get; init; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Структурное «зрение» агента: чтение модели как данных, а не картинки — дерево построения,
|
||||||
|
/// тела, топология, переменные, drill-down по граням/рёбрам и измерения. Всё через API5
|
||||||
|
/// <c>ksPart</c> на STA-потоке <see cref="KompasDispatcher"/>.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Собрать структурный «паспорт» активной детали одним проходом.</summary>
|
||||||
|
public Task<ModelDescription> 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<BodyInfo>(), FaceTypes = Array.Empty<string>(),
|
||||||
|
EdgeTypes = Array.Empty<string>(), Features = Array.Empty<FeatureInfo>(),
|
||||||
|
Imported = false, Variables = Array.Empty<VariableInfo>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>Перечислить узлы дерева построения по порядку.</summary>
|
||||||
|
public Task<IReadOnlyList<FeatureInfo>> ListFeaturesAsync(CancellationToken ct = default)
|
||||||
|
=> _dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var part = GetTopPart();
|
||||||
|
try { return ReadFeatures(part); }
|
||||||
|
finally { Release(part); }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
/// <summary>Перечислить тела детали.</summary>
|
||||||
|
public Task<IReadOnlyList<BodyInfo>> ListBodiesAsync(CancellationToken ct = default)
|
||||||
|
=> _dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var part = GetTopPart();
|
||||||
|
try { return ReadBodies(part); }
|
||||||
|
finally { Release(part); }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
/// <summary>Перечислить переменные модели.</summary>
|
||||||
|
public Task<IReadOnlyList<VariableInfo>> ListVariablesAsync(CancellationToken ct = default)
|
||||||
|
=> _dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var part = GetTopPart();
|
||||||
|
try { return ReadVariables(part); }
|
||||||
|
finally { Release(part); }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
/// <summary>Детали грани по индексу из list_faces: тип, площадь, нормаль, радиус, число рёбер.</summary>
|
||||||
|
public Task<FaceDetail> 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);
|
||||||
|
|
||||||
|
/// <summary>Детали ребра по индексу из list_edges: тип, длина, смежные грани, концевые вершины.</summary>
|
||||||
|
public Task<EdgeDetail> 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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Измерить расстояние и угол между двумя объектами модели. <paramref name="kind1"/>/<paramref name="kind2"/>
|
||||||
|
/// — face|edge|vertex; индексы — из list_faces/list_edges.
|
||||||
|
/// </summary>
|
||||||
|
public Task<MeasureResult> 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<FeatureInfo> 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<FeatureInfo>();
|
||||||
|
|
||||||
|
int n = tree.GetCount();
|
||||||
|
var list = new List<FeatureInfo>(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<BodyInfo> ReadBodies(ksPart part)
|
||||||
|
{
|
||||||
|
if (part.BodyCollection() is not ksBodyCollection bc) return Array.Empty<BodyInfo>();
|
||||||
|
int n = bc.GetCount();
|
||||||
|
var list = new List<BodyInfo>(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<VariableInfo> ReadVariables(ksPart part)
|
||||||
|
{
|
||||||
|
if (part.VariableCollection() is not ksVariableCollection vc) return Array.Empty<VariableInfo>();
|
||||||
|
int n = vc.GetCount();
|
||||||
|
var list = new List<VariableInfo>(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<string> faceTypes, List<string> edgeTypes) ReadTopology(ksPart part)
|
||||||
|
{
|
||||||
|
var faceTypes = new List<string>();
|
||||||
|
var edgeTypes = new List<string>();
|
||||||
|
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<string?> g) { try { return g() ?? ""; } catch { return ""; } }
|
||||||
|
private static double SafeNum(Func<double> g) { try { return g(); } catch { return 0; } }
|
||||||
|
private static bool SafeBool(Func<bool> 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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,53 @@ public sealed class QueryService
|
|||||||
}
|
}
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Перечислить компоненты верхнего уровня активной сборки (детали/подсборки): имя,
|
||||||
|
/// обозначение, файл, признак детали и габарит. Обход через API7 <c>TopPart → Parts</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Task<IReadOnlyList<ComponentInfo>> ListComponentsAsync(CancellationToken ct = default)
|
||||||
|
=> _dispatcher.InvokeAsync<IReadOnlyList<ComponentInfo>>(() =>
|
||||||
|
{
|
||||||
|
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<ComponentInfo>(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<string?> get)
|
||||||
|
{
|
||||||
|
try { return get() ?? string.Empty; } catch { return string.Empty; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SafeBool(Func<bool> get)
|
||||||
|
{
|
||||||
|
try { return get(); } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
// ST_MIX_MM (0x1) — площади граней в мм².
|
// ST_MIX_MM (0x1) — площади граней в мм².
|
||||||
private const uint MixMm = 0x1;
|
private const uint MixMm = 0x1;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Query;
|
||||||
|
|
||||||
|
/// <summary>Переменная модели (имя, выражение, значение, признаки).</summary>
|
||||||
|
public sealed record VariableInfo
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Выражение переменной (может быть числом или формулой со ссылками на другие переменные).</summary>
|
||||||
|
public required string Expression { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Вычисленное числовое значение.</summary>
|
||||||
|
public required double Value { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Внешняя переменная (доступна из вышестоящих документов/исполнений).</summary>
|
||||||
|
public required bool External { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Информационная переменная (вычисляется, не задаёт геометрию).</summary>
|
||||||
|
public required bool Information { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Расшифровка кодов ошибок построения КОМПАС (<c>ErrorType3d</c>), которые возвращает
|
||||||
|
/// <c>IFeature7.ObjectError</c>. 0 — ошибки нет.
|
||||||
|
/// </summary>
|
||||||
|
public static class Errors3d
|
||||||
|
{
|
||||||
|
/// <summary>true, если код означает отсутствие ошибки.</summary>
|
||||||
|
public static bool IsOk(int code) => code == 0;
|
||||||
|
|
||||||
|
/// <summary>Человекочитаемое описание кода ошибки построения.</summary>
|
||||||
|
public static string Describe(int code) => code switch
|
||||||
|
{
|
||||||
|
0 => "ошибки нет",
|
||||||
|
-7 => "документ не активизирован или не является деталью/сборкой",
|
||||||
|
-1 => "аварийное завершение операции",
|
||||||
|
54 => "неизвестная ошибка построения (операция не выполнена)",
|
||||||
|
123 => "объекты пересекаются",
|
||||||
|
130 => "ошибочная топология",
|
||||||
|
135 => "не найдены одна или несколько исходных операций",
|
||||||
|
144 => "невозможная операция",
|
||||||
|
145 => "операция потеряла опорные объекты",
|
||||||
|
179 => "контур невозможно использовать для заданного перемещения",
|
||||||
|
185 => "объект не найден",
|
||||||
|
193 => "доступ к объекту запрещён",
|
||||||
|
199 => "параметр операции был изменён",
|
||||||
|
202 => "ошибка при создании скругления грани",
|
||||||
|
_ => $"ошибка построения #{code}",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Kompas.Mcp.Core.Validation;
|
||||||
|
|
||||||
|
/// <summary>Диагностика операции дерева построения, находящейся в ошибке/требующей перестроения.</summary>
|
||||||
|
public sealed record OperationError
|
||||||
|
{
|
||||||
|
/// <summary>Имя операции в дереве.</summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Код ошибки построения (ErrorType3d); 0 — ошибки нет.</summary>
|
||||||
|
public required int ErrorCode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Человекочитаемое описание кода.</summary>
|
||||||
|
public required string Description { get; init; }
|
||||||
|
|
||||||
|
/// <summary>false — операция вырождена (не дала корректной геометрии).</summary>
|
||||||
|
public required bool Valid { get; init; }
|
||||||
|
|
||||||
|
/// <summary>true — операция помечена «требует перестроения» (флаг ksFSRebuild).</summary>
|
||||||
|
public required bool NeedsRebuild { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using Kompas.Mcp.Core.Threading;
|
||||||
|
using Kompas6Constants;
|
||||||
|
using KompasAPI7;
|
||||||
|
|
||||||
|
namespace Kompas.Mcp.Core.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Валидация построения активной детали/сборки: какие операции в ошибке или требуют перестроения.
|
||||||
|
/// Читает <c>IFeature7.Valid</c> / <c>ObjectError</c> / <c>State</c> по дереву (<c>SubFeatures</c>).
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проверить активную деталь/сборку: вернуть список операций, которые вырождены (Valid=false),
|
||||||
|
/// имеют код ошибки (ObjectError≠0) или помечены «требует перестроения». Пустой список — построение чистое.
|
||||||
|
/// </summary>
|
||||||
|
public Task<IReadOnlyList<OperationError>> ValidatePartAsync(CancellationToken ct = default)
|
||||||
|
=> _dispatcher.InvokeAsync<IReadOnlyList<OperationError>>(() =>
|
||||||
|
{
|
||||||
|
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<OperationError>();
|
||||||
|
CheckFeature(top, result);
|
||||||
|
foreach (var f in EnumerateSubFeatures(top))
|
||||||
|
CheckFeature(f, result);
|
||||||
|
return result;
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
private static IEnumerable<IFeature7> 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<OperationError> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
using Kompas.Mcp.Core;
|
using Kompas.Mcp.Core;
|
||||||
|
using Kompas.Mcp.Core.Conversion;
|
||||||
using Kompas.Mcp.Core.Documents;
|
using Kompas.Mcp.Core.Documents;
|
||||||
|
using Kompas.Mcp.Core.Editing;
|
||||||
using Kompas.Mcp.Core.Modeling;
|
using Kompas.Mcp.Core.Modeling;
|
||||||
using Kompas.Mcp.Core.Query;
|
using Kompas.Mcp.Core.Query;
|
||||||
using Kompas.Mcp.Core.Threading;
|
using Kompas.Mcp.Core.Threading;
|
||||||
|
using Kompas.Mcp.Core.Validation;
|
||||||
using Kompas.Mcp.Core.Vision;
|
using Kompas.Mcp.Core.Vision;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -21,6 +24,11 @@ builder.Services.AddSingleton<DocumentService>();
|
|||||||
builder.Services.AddSingleton<PartModeler>();
|
builder.Services.AddSingleton<PartModeler>();
|
||||||
builder.Services.AddSingleton<SnapshotService>();
|
builder.Services.AddSingleton<SnapshotService>();
|
||||||
builder.Services.AddSingleton<QueryService>();
|
builder.Services.AddSingleton<QueryService>();
|
||||||
|
builder.Services.AddSingleton<ModelInspectionService>();
|
||||||
|
builder.Services.AddSingleton<ConversionService>();
|
||||||
|
builder.Services.AddSingleton<FaceEditService>();
|
||||||
|
builder.Services.AddSingleton<SolidEditService>();
|
||||||
|
builder.Services.AddSingleton<ValidationService>();
|
||||||
|
|
||||||
// MCP-сервер поверх stdio; инструменты находятся по атрибутам в этой сборке.
|
// MCP-сервер поверх stdio; инструменты находятся по атрибутам в этой сборке.
|
||||||
builder.Services
|
builder.Services
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Инструменты обмена форматами: импорт/экспорт STEP.</summary>
|
||||||
|
[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<string> 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<string> 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Инструменты прямого редактирования тела (без дерева построения).</summary>
|
||||||
|
[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<string> 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<string> 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<string> 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<string> BooleanUnion()
|
||||||
|
{
|
||||||
|
await session.ConnectAsync();
|
||||||
|
await solidEdit.UnionAllAsync();
|
||||||
|
return await WithValidation("Тела объединены.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Дописать к сообщению результат проверки построения (операция могла «пройти», оставив ошибку).</summary>
|
||||||
|
private async Task<string> WithValidation(string baseMsg)
|
||||||
|
{
|
||||||
|
var errs = await validation.ValidatePartAsync();
|
||||||
|
if (errs.Count == 0) return baseMsg + " Построение чистое.";
|
||||||
|
var lines = errs.Select(e => $"\"{e.Name}\" (код {e.ErrorCode}: {e.Description})");
|
||||||
|
return baseMsg + $" ⚠ Внимание: {errs.Count} операц. в ошибке: " + string.Join("; ", lines) +
|
||||||
|
". Результат может быть некорректным.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using Kompas.Mcp.Core;
|
||||||
|
using Kompas.Mcp.Core.Query;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace Kompas.Mcp.Host.Tools;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Структурное «зрение» агента — чтение модели как данных, а не картинки.
|
||||||
|
/// Предпочтительнее снимка (<c>model_snapshot</c>): точнее и дешевле по контексту.
|
||||||
|
/// </summary>
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class InspectionTools(KompasSession session, ModelInspectionService inspection)
|
||||||
|
{
|
||||||
|
[McpServerTool(Name = "describe_model")]
|
||||||
|
[Description("ПРЕДПОЧТИТЕЛЬНЫЙ способ «увидеть» активную деталь — структурный паспорт одним вызовом: " +
|
||||||
|
"габарит, МЦХ, тела, сводка топологии (грани/рёбра по типам), дерево построения с параметрами и переменные. " +
|
||||||
|
"Вызывайте ПЕРЕД model_snapshot: это точнее снимка и дешевле по контексту. Снимок берите только для " +
|
||||||
|
"визуально-пространственных вопросов, на которые паспорт не отвечает.")]
|
||||||
|
public async Task<string> DescribeModel()
|
||||||
|
{
|
||||||
|
await session.ConnectAsync();
|
||||||
|
var d = await inspection.DescribeModelAsync();
|
||||||
|
return InspectionText.Render(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool(Name = "list_features")]
|
||||||
|
[Description("Дерево построения активной детали по порядку: индекс, имя операции и ключевые параметры " +
|
||||||
|
"(глубина/радиус/катеты). Для импортированных моделей (STEP) истории нет — только начало координат и тело.")]
|
||||||
|
public async Task<string> 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<string> 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<string> 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<string> DescribeFace([Description("Индекс грани из list_faces")] int faceIndex)
|
||||||
|
{
|
||||||
|
await session.ConnectAsync();
|
||||||
|
var f = await inspection.DescribeFaceAsync(faceIndex);
|
||||||
|
var sb = Inv($"Грань [{f.Index}]: {f.Type}, площадь {f.Area:F2} мм², ограничивающих рёбер: {f.EdgeCount}");
|
||||||
|
if (f.Radius is { } r) sb += Inv($", радиус {r:F2} мм");
|
||||||
|
if (f.Normal is { } n) sb += Inv($", нормаль ({n.X:F3}, {n.Y:F3}, {n.Z:F3})");
|
||||||
|
return sb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool(Name = "describe_edge")]
|
||||||
|
[Description("Детали ребра по индексу из list_edges: тип, длина, две смежные грани и координаты концевых вершин.")]
|
||||||
|
public async Task<string> DescribeEdge([Description("Индекс ребра из list_edges")] int edgeIndex)
|
||||||
|
{
|
||||||
|
await session.ConnectAsync();
|
||||||
|
var e = await inspection.DescribeEdgeAsync(edgeIndex);
|
||||||
|
var sb = Inv($"Ребро [{e.Index}]: {e.Type}, длина {e.Length:F2} мм");
|
||||||
|
if (e.AdjacentFace1 is not null || e.AdjacentFace2 is not null)
|
||||||
|
sb += $", смежные грани: {e.AdjacentFace1 ?? "?"} / {e.AdjacentFace2 ?? "?"}";
|
||||||
|
if (e.Start is { } s && e.End is { } en)
|
||||||
|
sb += Inv($", концы ({s.X:F2}, {s.Y:F2}, {s.Z:F2}) → ({en.X:F2}, {en.Y:F2}, {en.Z:F2})");
|
||||||
|
return sb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool(Name = "measure")]
|
||||||
|
[Description("Измерить расстояние и угол между двумя объектами активной детали. kind1/kind2: face|edge|vertex; " +
|
||||||
|
"индексы — из list_faces/list_edges. Возвращает минимальное расстояние (мм) и угол (°, если применим).")]
|
||||||
|
public async Task<string> 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);
|
||||||
|
}
|
||||||
@@ -19,6 +19,22 @@ public sealed class QueryTools(KompasSession session, QueryService query)
|
|||||||
$"центр масс: ({i.CenterX:F2}, {i.CenterY:F2}, {i.CenterZ:F2}).";
|
$"центр масс: ({i.CenterX:F2}, {i.CenterY:F2}, {i.CenterZ:F2}).";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[McpServerTool(Name = "list_components")]
|
||||||
|
[Description("Перечислить компоненты верхнего уровня активной сборки: индекс, имя, обозначение, признак детали и габарит (мм). Индекс/имя — для последующего извлечения или осмотра детали.")]
|
||||||
|
public async Task<string> 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")]
|
[McpServerTool(Name = "list_faces")]
|
||||||
[Description("Перечислить грани активной детали: индекс, тип поверхности (plane/cylinder/cone/sphere/torus/nurbs) и площадь (мм²). Индекс используйте в sketch_create_on_face_index. Удобно для надёжного выбора грани без угадывания координат.")]
|
[Description("Перечислить грани активной детали: индекс, тип поверхности (plane/cylinder/cone/sphere/torus/nurbs) и площадь (мм²). Индекс используйте в sketch_create_on_face_index. Удобно для надёжного выбора грани без угадывания координат.")]
|
||||||
public async Task<string> ListFaces()
|
public async Task<string> ListFaces()
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using Kompas.Mcp.Core;
|
||||||
|
using Kompas.Mcp.Core.Validation;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace Kompas.Mcp.Host.Tools;
|
||||||
|
|
||||||
|
/// <summary>Инструменты валидации построения.</summary>
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class ValidationTools(KompasSession session, ValidationService validation)
|
||||||
|
{
|
||||||
|
[McpServerTool(Name = "validate_part")]
|
||||||
|
[Description("Проверить построение активной детали/сборки: вернуть операции, которые в ошибке (вырождены), " +
|
||||||
|
"имеют код ошибки ErrorType3d или помечены «требует перестроения» (красный «!» в дереве). " +
|
||||||
|
"Пустой результат — построение чистое. Вызывай после операций редактирования (move_face, extrude и т.п.).")]
|
||||||
|
public async Task<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ namespace Kompas.Mcp.Host.Tools;
|
|||||||
public sealed class VisionTools(KompasSession session, SnapshotService snapshot)
|
public sealed class VisionTools(KompasSession session, SnapshotService snapshot)
|
||||||
{
|
{
|
||||||
[McpServerTool(Name = "model_snapshot")]
|
[McpServerTool(Name = "model_snapshot")]
|
||||||
[Description("Снять текущий вид активной 3D-модели в PNG и вернуть как изображение — чтобы увидеть результат построения и решить следующий шаг.")]
|
[Description("Растровый снимок активной 3D-модели в PNG — FALLBACK. Сначала используйте describe_model (структурный паспорт: " +
|
||||||
|
"дерево, топология, габарит, МЦХ) — он точнее и дешевле. Снимок берите только для визуально-пространственных " +
|
||||||
|
"вопросов, на которые структурные запросы не отвечают: взаимное расположение, ориентация, общая форма.")]
|
||||||
public async Task<ImageContentBlock> Snapshot(
|
public async Task<ImageContentBlock> Snapshot(
|
||||||
[Description("Разрешение в DPI; 0 — экранное")] int resolution = 0)
|
[Description("Разрешение в DPI; 0 — экранное")] int resolution = 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Kompas.Mcp.Core.Validation;
|
||||||
|
|
||||||
|
namespace Kompas.Mcp.Tests;
|
||||||
|
|
||||||
|
/// <summary>Юнит-тесты расшифровки кодов ошибок построения (ErrorType3d). COM не требуется.</summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using Kompas.Mcp.Core.Query;
|
||||||
|
|
||||||
|
namespace Kompas.Mcp.Tests;
|
||||||
|
|
||||||
|
/// <summary>Юнит-тесты чистого рендера/группировки структурного «паспорта». COM не требуется.</summary>
|
||||||
|
[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<string>()));
|
||||||
|
|
||||||
|
// ── Детект импорта без истории ───────────────────────────────────────
|
||||||
|
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<VariableInfo>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<VariableInfo>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<BodyInfo>(), FaceTypes = Array.Empty<string>(), EdgeTypes = Array.Empty<string>(),
|
||||||
|
Imported = false, Features = Array.Empty<FeatureInfo>(), Variables = Array.Empty<VariableInfo>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<FeatureInfo>(),
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Интеграция: импорт/экспорт STEP и обход компонентов сборки.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Интеграция: прямое редактирование — перемещение грани (FaceMover).</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using Kompas.Mcp.Core.Documents;
|
||||||
|
using Kompas.Mcp.Core.Modeling;
|
||||||
|
using Kompas.Mcp.Core.Query;
|
||||||
|
|
||||||
|
namespace Kompas.Mcp.Tests.Integration;
|
||||||
|
|
||||||
|
/// <summary>Интеграция: структурное «зрение» — дерево, топология, drill-down, измерения.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Построить эталон: прямоугольник 40×20 → выдавить 15 мм → скругление R3 первого ребра.</summary>
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Интеграция: рассечение тела, перемещение тела, объединение (cut→spread→union).</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Kompas.Mcp.Core.Documents;
|
||||||
|
using Kompas.Mcp.Core.Modeling;
|
||||||
|
using Kompas.Mcp.Core.Validation;
|
||||||
|
|
||||||
|
namespace Kompas.Mcp.Tests.Integration;
|
||||||
|
|
||||||
|
/// <summary>Интеграция: валидация построения (ошибки/перестроение операций).</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Kompas.Mcp.Core.Conversion;
|
||||||
|
|
||||||
|
namespace Kompas.Mcp.Tests;
|
||||||
|
|
||||||
|
/// <summary>Юнит-тесты маппинга формата STEP в коды конвертера КОМПАС. COM не требуется.</summary>
|
||||||
|
[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<ArgumentException>(() => StepFormats.Parse("iges"));
|
||||||
|
}
|
||||||
+389
-109
@@ -8,7 +8,11 @@ build_kompas3d_sdk.py — Преобразование KOMPAS SDK HTML-спра
|
|||||||
- Группировка: одна MD-статья на COM-интерфейс/тему (по префиксу имени файла).
|
- Группировка: одна MD-статья на COM-интерфейс/тему (по префиксу имени файла).
|
||||||
- Изображения: копируются файлами в resources/ (не base64).
|
- Изображения: копируются файлами в resources/ (не base64).
|
||||||
- KsAPI (ksapi_*.html): пропускаются.
|
- 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
|
python tools/build_kompas3d_sdk.py
|
||||||
@@ -46,9 +50,31 @@ NOISE_WORDS = {
|
|||||||
"подразделы:", "(отсутствуют)", "поделиться",
|
"подразделы:", "(отсутствуют)", "поделиться",
|
||||||
}
|
}
|
||||||
FOOTER_RE = re.compile(r"©?\s*ООО\s*«?АСКОН")
|
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:
|
def is_informative_image(src: str) -> bool:
|
||||||
basename = os.path.basename(src).lower()
|
basename = os.path.basename(src).lower()
|
||||||
@@ -61,52 +87,203 @@ def is_informative_image(src: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class TopicExtractor(HTMLParser):
|
def copy_image(img_path: Path) -> str:
|
||||||
BLOCK_TAGS = {"p", "div", "br", "tr", "li", "h1", "h2", "h3", "h4",
|
"""Копирует изображение в resources/ с collision-safe именованием."""
|
||||||
"table", "pre", "td", "th"}
|
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-зоны, <pre>/<code>) ───────────────────
|
||||||
|
|
||||||
|
class HtmlToMdExtractor(HTMLParser):
|
||||||
|
"""
|
||||||
|
Парсер контентной зоны Help & Manual WebHelp:
|
||||||
|
- Захватывает только div#hmpagebody_scroller (содержимое без навигации).
|
||||||
|
- Рендерит <table> в Markdown-таблицы.
|
||||||
|
- Сохраняет <pre> как code-fence, <code> как 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",
|
VOID_TAGS = {"img", "input", "link", "meta", "hr", "area", "base",
|
||||||
"col", "embed", "source", "track", "wbr"}
|
"col", "embed", "source", "track", "wbr"}
|
||||||
|
# IDs которые служат точкой входа (по убыванию специфичности)
|
||||||
|
ENTRY_IDS = ("hmpagebody_scroller", "hmpagebody", "topicbody")
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(convert_charrefs=True)
|
super().__init__(convert_charrefs=True)
|
||||||
self.depth = 0
|
# Capture state
|
||||||
self.capturing = False
|
self.depth: int = 0
|
||||||
self.skip = 0
|
self.capturing: bool = False
|
||||||
self.out = []
|
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)
|
attrd = dict(attrs)
|
||||||
if not self.capturing and attrd.get("id") == "topicbody":
|
|
||||||
self.capturing = True
|
# Try to activate capturing on the content div
|
||||||
self.depth = 1
|
|
||||||
return
|
|
||||||
if not self.capturing:
|
if not self.capturing:
|
||||||
|
elem_id = attrd.get("id", "")
|
||||||
|
if elem_id in self.ENTRY_IDS:
|
||||||
|
self.capturing = True
|
||||||
|
self.depth = 1
|
||||||
return
|
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"):
|
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:
|
if tag in self.BLOCK_TAGS:
|
||||||
self.out.append("\n")
|
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
|
self.depth += 1
|
||||||
|
|
||||||
def handle_endtag(self, tag):
|
def handle_endtag(self, tag: str):
|
||||||
if not self.capturing:
|
if not self.capturing:
|
||||||
return
|
return
|
||||||
if tag in ("script", "style") and self.skip:
|
|
||||||
self.skip -= 1
|
# Table exit
|
||||||
if tag != "br":
|
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
|
self.depth -= 1
|
||||||
if self.depth <= 0:
|
if self.depth <= 0:
|
||||||
self.capturing = False
|
self.capturing = False
|
||||||
|
|
||||||
def handle_data(self, data):
|
def handle_data(self, data: str):
|
||||||
if self.capturing and not self.skip:
|
if not self.capturing or self.skip_script:
|
||||||
self.out.append(data)
|
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]:
|
def clean_text(raw: str) -> list[str]:
|
||||||
cleaned = []
|
"""Очищает текст; сохраняет code-fence блоки без изменений."""
|
||||||
|
cleaned: list[str] = []
|
||||||
|
in_fence = False
|
||||||
for line in raw.splitlines():
|
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()
|
s = re.sub(r"[ \t]+", " ", line).strip()
|
||||||
if not s:
|
if not s:
|
||||||
continue
|
continue
|
||||||
@@ -114,10 +291,101 @@ def clean_text(raw: str) -> list[str]:
|
|||||||
continue
|
continue
|
||||||
if FOOTER_RE.search(s):
|
if FOOTER_RE.search(s):
|
||||||
break
|
break
|
||||||
|
if ">" in s and BREADCRUMB_RE.match(s):
|
||||||
|
continue
|
||||||
cleaned.append(s)
|
cleaned.append(s)
|
||||||
|
if in_fence:
|
||||||
|
cleaned.append("```")
|
||||||
return cleaned
|
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-блоки (от <pre>/<code>) пропускаются.
|
||||||
|
"""
|
||||||
|
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-блоки (из <pre>)
|
||||||
|
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]]]:
|
def extract_title_and_breadcrumbs(html_content: str) -> tuple[str, list[tuple[str, str]]]:
|
||||||
title = ""
|
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()
|
title = hmod.unescape(m.group(1)).strip()
|
||||||
break
|
break
|
||||||
if not title:
|
if not title:
|
||||||
for m in re.finditer(r'<p\s+class="topictitle"[^>]*>(.*?)</p>', html_content, re.DOTALL):
|
for m in re.finditer(
|
||||||
|
r'<p\s+class="topictitle"[^>]*>(.*?)</p>', html_content, re.DOTALL):
|
||||||
title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
|
title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
|
||||||
title = hmod.unescape(title)
|
title = hmod.unescape(title)
|
||||||
break
|
break
|
||||||
breadcrumbs = []
|
breadcrumbs: list[tuple[str, str]] = []
|
||||||
bc_match = re.search(
|
bc_match = re.search(
|
||||||
r'<p\s+id="ptopic_breadcrumbs"[^>]*>(.*?)</p>',
|
r'<p\s+id="ptopic_breadcrumbs"[^>]*>(.*?)</p>', html_content, re.DOTALL)
|
||||||
html_content, re.DOTALL)
|
|
||||||
if bc_match:
|
if bc_match:
|
||||||
bc_html = bc_match.group(1)
|
bc_html = bc_match.group(1)
|
||||||
for a_m in re.finditer(r'<a[^>]+href="([^"]*)"[^>]*>(.*?)</a>', bc_html, re.DOTALL):
|
for a_m in re.finditer(
|
||||||
|
r'<a[^>]+href="([^"]*)"[^>]*>(.*?)</a>', bc_html, re.DOTALL):
|
||||||
href = a_m.group(1)
|
href = a_m.group(1)
|
||||||
text = re.sub(r'<[^>]+>', '', a_m.group(2)).strip()
|
text = re.sub(r'<[^>]+>', '', a_m.group(2)).strip()
|
||||||
text = hmod.unescape(text)
|
text = hmod.unescape(text)
|
||||||
@@ -144,16 +413,18 @@ def extract_title_and_breadcrumbs(html_content: str) -> tuple[str, list[tuple[st
|
|||||||
return title, breadcrumbs
|
return title, breadcrumbs
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Индекс ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def read_index() -> list[dict]:
|
def read_index() -> list[dict]:
|
||||||
pages = []
|
pages: list[dict] = []
|
||||||
with open(INDEX_PATH, "r", encoding="utf-8") as f:
|
with open(INDEX_PATH, "r", encoding="utf-8") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
parts = line.rstrip("\n").split("\t")
|
parts = line.rstrip("\n").split("\t")
|
||||||
if len(parts) >= 1 and parts[0]:
|
if len(parts) >= 1 and parts[0]:
|
||||||
pages.append({
|
pages.append({
|
||||||
"file": parts[0],
|
"file": parts[0],
|
||||||
"title": parts[1] if len(parts) > 1 else "",
|
"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
|
return pages
|
||||||
|
|
||||||
@@ -162,14 +433,11 @@ def read_index() -> list[dict]:
|
|||||||
|
|
||||||
def get_group_key(filename: str) -> str:
|
def get_group_key(filename: str) -> str:
|
||||||
"""
|
"""
|
||||||
Извлекает ключ группы из имени файла.
|
Ключ = часть до первого '_' (удаляя ведущий '_').
|
||||||
Стратегия: часть до первого '_' (после удаления ведущего '_').
|
'iapplication_visible.html' → 'iapplication'
|
||||||
Примеры:
|
'_dkgax_caption.html' → 'dkgax'
|
||||||
'iapplication_visible.html' → 'iapplication'
|
'3d_curves.html' → '3d'
|
||||||
'iapplication_visible_ex.html' → 'iapplication'
|
'zoomtypeenum.html' → 'zoomtypeenum'
|
||||||
'_dkgax_caption.html' → 'dkgax'
|
|
||||||
'3d_curves.html' → '3d'
|
|
||||||
'zoomtypeenum.html' → 'zoomtypeenum' (standalone)
|
|
||||||
"""
|
"""
|
||||||
stem = filename.replace(".html", "")
|
stem = filename.replace(".html", "")
|
||||||
if stem.startswith("_"):
|
if stem.startswith("_"):
|
||||||
@@ -179,15 +447,14 @@ def get_group_key(filename: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def build_groups(pages: list[dict]) -> dict[str, list[dict]]:
|
def build_groups(pages: list[dict]) -> dict[str, list[dict]]:
|
||||||
"""
|
"""Группирует страницы, пропуская KsAPI и TOC-страницы."""
|
||||||
Разбивает страницы на группы по ключу.
|
|
||||||
Пропускает ksapi_* (KsAPI — Qt/C++ cross-platform, не нужен).
|
|
||||||
"""
|
|
||||||
raw: dict[str, list[dict]] = defaultdict(list)
|
raw: dict[str, list[dict]] = defaultdict(list)
|
||||||
for page in pages:
|
for page in pages:
|
||||||
fname = page["file"]
|
fname = page["file"]
|
||||||
if fname.startswith("ksapi_"):
|
if fname.startswith("ksapi_"):
|
||||||
continue
|
continue
|
||||||
|
if is_toc_page_title(page.get("title", "")):
|
||||||
|
continue
|
||||||
key = get_group_key(fname)
|
key = get_group_key(fname)
|
||||||
raw[key].append(page)
|
raw[key].append(page)
|
||||||
return dict(raw)
|
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:
|
def classify_type(group_key: str, representative_title: str) -> str:
|
||||||
"""Определяет тип: 'interface', 'enum', 'struct', 'guide'."""
|
"""Определяет тип статьи: 'interface', 'enum', 'struct', 'guide'."""
|
||||||
key = group_key.lower()
|
key = group_key.lower()
|
||||||
title = representative_title.lower()
|
title = representative_title.lower()
|
||||||
|
|
||||||
|
# Enum — специфичный признак, проверяем первым
|
||||||
if key.endswith("enum") or "enum" in title or "перечислени" in title:
|
if key.endswith("enum") or "enum" in title or "перечислени" in title:
|
||||||
return "enum"
|
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):
|
if re.match(r"^i[a-z]", key):
|
||||||
return "interface"
|
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"
|
return "struct"
|
||||||
if any(s in title for s in ["параметры", "структура", "param "]):
|
if any(s in title for s in ("параметры", "структура", "param ")):
|
||||||
return "struct"
|
return "struct"
|
||||||
|
|
||||||
return "guide"
|
return "guide"
|
||||||
|
|
||||||
|
|
||||||
def extract_api_version(title: str, breadcrumbs: list[tuple[str, str]]) -> list[str]:
|
def extract_api_version(title: str, breadcrumbs: list[tuple[str, str]]) -> list[str]:
|
||||||
all_text = (title + " " + " ".join(t for t, _ in breadcrumbs)).lower()
|
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:
|
if "версия 7" in all_text or "api7" in all_text or "api 7" in all_text:
|
||||||
versions.append("api7")
|
versions.append("api7")
|
||||||
if "api5" in all_text or "api 5" in all_text:
|
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]:
|
def extract_domain_tags(title: str, breadcrumbs: list[tuple[str, str]]) -> list[str]:
|
||||||
all_text = (title + " " + " ".join(t for t, _ in breadcrumbs)).lower()
|
# Вес: заголовок + последние 2 breadcrumb (более точно, чем все предки)
|
||||||
domain_map = {
|
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", "тело"],
|
"3d": ["3d", "трёхмерн", "3д", "solid", "тело"],
|
||||||
"2d": ["2d", "двухмерн", "2д", "плоск"],
|
"2d": ["2d", "двухмерн", "2д", "плоск"],
|
||||||
"sketch": ["эскиз"],
|
"sketch": ["эскиз"],
|
||||||
@@ -232,7 +512,7 @@ def extract_domain_tags(title: str, breadcrumbs: list[tuple[str, str]]) -> list[
|
|||||||
"drawing": ["чертёж", "чертеж", "drawing", "выносн"],
|
"drawing": ["чертёж", "чертеж", "drawing", "выносн"],
|
||||||
"feature": ["операци", "feature", "выдавлив", "вращени", "скругл", "фаск"],
|
"feature": ["операци", "feature", "выдавлив", "вращени", "скругл", "фаск"],
|
||||||
}
|
}
|
||||||
found = []
|
found: list[str] = []
|
||||||
for domain, keywords in domain_map.items():
|
for domain, keywords in domain_map.items():
|
||||||
if any(k in all_text for k in keywords):
|
if any(k in all_text for k in keywords):
|
||||||
found.append(domain)
|
found.append(domain)
|
||||||
@@ -245,10 +525,10 @@ def build_tags(article_type: str, api_versions: list[str],
|
|||||||
return list(dict.fromkeys(tags))
|
return list(dict.fromkeys(tags))
|
||||||
|
|
||||||
|
|
||||||
# ─── Парсинг HTML ────────────────────────────────────────────────────────────
|
# ─── Парсинг HTML-страницы ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def parse_page(fpath: Path) -> Optional[dict]:
|
def parse_page(fpath: Path) -> Optional[dict]:
|
||||||
"""Парсит одну HTML-страницу. Возвращает None если страница пустая."""
|
"""Парсит HTML-страницу. Возвращает None если контент пустой."""
|
||||||
try:
|
try:
|
||||||
html_content = fpath.read_text(encoding="utf-8-sig")
|
html_content = fpath.read_text(encoding="utf-8-sig")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -257,7 +537,7 @@ def parse_page(fpath: Path) -> Optional[dict]:
|
|||||||
|
|
||||||
title, breadcrumbs = extract_title_and_breadcrumbs(html_content)
|
title, breadcrumbs = extract_title_and_breadcrumbs(html_content)
|
||||||
|
|
||||||
extractor = TopicExtractor()
|
extractor = HtmlToMdExtractor()
|
||||||
extractor.feed(html_content)
|
extractor.feed(html_content)
|
||||||
raw_text = "".join(extractor.out)
|
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:
|
if not body_lines or len(body_lines) <= 2:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Информативные изображения
|
||||||
image_srcs: list[tuple[Path, str]] = []
|
image_srcs: list[tuple[Path, str]] = []
|
||||||
for m in re.finditer(r'<img[^>]+>', html_content):
|
for m in re.finditer(r'<img[^>]+>', html_content):
|
||||||
tag = m.group()
|
tag = m.group()
|
||||||
@@ -279,9 +560,6 @@ def parse_page(fpath: Path) -> Optional[dict]:
|
|||||||
if img_path.exists():
|
if img_path.exists():
|
||||||
image_srcs.append((img_path, alt))
|
image_srcs.append((img_path, alt))
|
||||||
|
|
||||||
# HTML entities are already decoded by TopicExtractor(convert_charrefs=True)
|
|
||||||
# No need to unescape again
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"title": title,
|
"title": title,
|
||||||
"breadcrumbs": breadcrumbs,
|
"breadcrumbs": breadcrumbs,
|
||||||
@@ -290,24 +568,7 @@ def parse_page(fpath: Path) -> Optional[dict]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ─── Изображения ─────────────────────────────────────────────────────────────
|
# ─── Генерация MD-статьи ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
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 ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
SUBDIR_FOR_TYPE = {
|
SUBDIR_FOR_TYPE = {
|
||||||
"interface": "interfaces",
|
"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],
|
def render_article(group_key: str, pages_data: list[dict],
|
||||||
article_type: str, source_files: list[str]) -> str:
|
article_type: str, source_files: list[str]) -> str:
|
||||||
"""Генерирует содержимое MD-файла для группы страниц."""
|
"""Генерирует содержимое MD-файла для группы страниц."""
|
||||||
@@ -328,9 +594,10 @@ def render_article(group_key: str, pages_data: list[dict],
|
|||||||
domain_tags = extract_domain_tags(title, breadcrumbs)
|
domain_tags = extract_domain_tags(title, breadcrumbs)
|
||||||
tags = build_tags(article_type, api_versions, domain_tags, group_key)
|
tags = build_tags(article_type, api_versions, domain_tags, group_key)
|
||||||
|
|
||||||
|
# YAML frontmatter с безопасным экранированием строк
|
||||||
fm_lines = [
|
fm_lines = [
|
||||||
"---",
|
"---",
|
||||||
f'title: "{title}"',
|
f'title: "{_escape_yaml_str(title)}"',
|
||||||
f"type: {article_type}",
|
f"type: {article_type}",
|
||||||
f"api: [{', '.join(api_versions)}]",
|
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)]
|
md_parts = ["\n".join(fm_lines)]
|
||||||
|
|
||||||
|
# Breadcrumbs — один раз в начале статьи
|
||||||
if breadcrumbs:
|
if breadcrumbs:
|
||||||
bc_text = " > ".join(t for t, _ in breadcrumbs)
|
bc_text = " > ".join(t for t, _ in breadcrumbs)
|
||||||
md_parts.append(f"*{bc_text}*\n")
|
md_parts.append(f"*{bc_text}*\n")
|
||||||
|
|
||||||
for page_data in pages_data:
|
for page_data in pages_data:
|
||||||
page_title = page_data["title"]
|
page_title = page_data["title"]
|
||||||
|
body_lines = list(page_data["body_lines"])
|
||||||
|
|
||||||
|
# Подзаголовок раздела для группы > 1 страницы
|
||||||
if len(pages_data) > 1:
|
if len(pages_data) > 1:
|
||||||
md_parts.append(f"\n## {page_title}\n")
|
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"]:
|
for img_path, alt in page_data["image_srcs"]:
|
||||||
img_name = copy_image(img_path)
|
img_name = copy_image(img_path)
|
||||||
alt_clean = alt.replace('"', "'")
|
alt_clean = alt.replace('"', "'")
|
||||||
md_parts.append(f"\n")
|
md_parts.append(f"\n")
|
||||||
|
|
||||||
md_parts.append("\n".join(page_data["body_lines"]))
|
md_parts.append("\n".join(body_lines))
|
||||||
md_parts.append("")
|
md_parts.append("")
|
||||||
|
|
||||||
return "\n".join(md_parts)
|
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():
|
def main():
|
||||||
@@ -388,7 +697,7 @@ def main():
|
|||||||
groups = build_groups(pages)
|
groups = build_groups(pages)
|
||||||
print(f"Групп после группировки: {len(groups)}")
|
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]] = []
|
index_entries: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
for group_key, group_pages in sorted(groups.items()):
|
for group_key, group_pages in sorted(groups.items()):
|
||||||
@@ -410,7 +719,7 @@ def main():
|
|||||||
source_files.append(fname)
|
source_files.append(fname)
|
||||||
if not rep_title:
|
if not rep_title:
|
||||||
rep_title = parsed["title"]
|
rep_title = parsed["title"]
|
||||||
stats["images_copied"] += len(parsed["image_srcs"])
|
stats["images"] += len(parsed["image_srcs"])
|
||||||
|
|
||||||
if not pages_data:
|
if not pages_data:
|
||||||
continue
|
continue
|
||||||
@@ -435,36 +744,7 @@ def main():
|
|||||||
print(f" Статей записано: {stats['processed']}")
|
print(f" Статей записано: {stats['processed']}")
|
||||||
print(f" Страниц пропущено: {stats['skipped']}")
|
print(f" Страниц пропущено: {stats['skipped']}")
|
||||||
print(f" Ошибок: {stats['errors']}")
|
print(f" Ошибок: {stats['errors']}")
|
||||||
print(f" Изображений: {stats['images_copied']}")
|
print(f" Изображений: {stats['images']}")
|
||||||
|
|
||||||
|
|
||||||
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)} ссылок")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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):
|
||||||
|
"""Извлекает текст из <div id='topicbody'>, пропуская 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'<img[^>]+>', 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 из <title> или <p class="topictitle">
|
||||||
|
title = ""
|
||||||
|
for m in re.finditer(r'<title>(.*?)</title>', html_content):
|
||||||
|
title = hmod.unescape(m.group(1)).strip()
|
||||||
|
break
|
||||||
|
if not title:
|
||||||
|
for m in re.finditer(r'<p\s+class="topictitle"[^>]*>(.*?)</p>', html_content, re.DOTALL):
|
||||||
|
title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
|
||||||
|
title = hmod.unescape(title)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Breadcrumbs из <p id="ptopic_breadcrumbs">
|
||||||
|
breadcrumbs = []
|
||||||
|
bc_match = re.search(
|
||||||
|
r'<p\s+id="ptopic_breadcrumbs"[^>]*>(.*?)</p>',
|
||||||
|
html_content, re.DOTALL,
|
||||||
|
)
|
||||||
|
if bc_match:
|
||||||
|
bc_html = bc_match.group(1)
|
||||||
|
for a_m in re.finditer(r'<a[^>]+href="([^"]*)"[^>]*>(.*?)</a>', 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'<link\s+rel="canonical"\s+href="[^"]*([^/]+\.html)"', html_content)
|
||||||
|
if not href_base_match:
|
||||||
|
# fallback: берём из breadcrumbs или URL
|
||||||
|
href_base_match = re.search(r'href="([^/]*\.html)"', html_content)
|
||||||
|
|
||||||
|
href_base = ""
|
||||||
|
if href_base_match:
|
||||||
|
href_base = os.path.basename(href_base_match.group(1)).replace(".html", "")
|
||||||
|
elif breadcrumbs:
|
||||||
|
href_base = os.path.basename(breadcrumbs[-1][1]).replace(".html", "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"breadcrumbs": breadcrumbs,
|
||||||
|
"body_lines": body_lines,
|
||||||
|
"images": images,
|
||||||
|
"href_base": href_base,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Построение единого Markdown-файла ───────────────────────────────────────
|
||||||
|
|
||||||
|
def build_index() -> 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"<a id=\"{href_base}\" name=\"{href_base}\"></a>\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"\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()
|
||||||
@@ -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'<img[^>]+>', 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'<title>(.*?)</title>', html_content):
|
||||||
|
title = hmod.unescape(m.group(1)).strip()
|
||||||
|
break
|
||||||
|
if not title:
|
||||||
|
for m in re.finditer(r'<p\s+class="topictitle"[^>]*>(.*?)</p>', html_content, re.DOTALL):
|
||||||
|
title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
|
||||||
|
title = hmod.unescape(title)
|
||||||
|
break
|
||||||
|
|
||||||
|
breadcrumbs = []
|
||||||
|
bc_match = re.search(
|
||||||
|
r'<p\s+id="ptopic_breadcrumbs"[^>]*>(.*?)</p>',
|
||||||
|
html_content, re.DOTALL,
|
||||||
|
)
|
||||||
|
if bc_match:
|
||||||
|
bc_html = bc_match.group(1)
|
||||||
|
for a_m in re.finditer(r'<a[^>]+href="([^"]*)"[^>]*>(.*?)</a>', 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]]:
|
||||||
|
"""
|
||||||
|
Находит все <a href="..."> в topicbody и возвращает список
|
||||||
|
[(anchor_text, href), ...] для последующей подстановки.
|
||||||
|
anchor_text — текст ссылки без HTML-тегов.
|
||||||
|
"""
|
||||||
|
# Ищем всё содержимое после id="topicbody" до <div class="topicfooter">
|
||||||
|
body_match = re.search(
|
||||||
|
r'id="topicbody">(.*?)(?:<div\s+class="topicfooter"|</body>)',
|
||||||
|
html_content, re.DOTALL)
|
||||||
|
if not body_match:
|
||||||
|
return []
|
||||||
|
body_html = body_match.group(1)
|
||||||
|
|
||||||
|
links = []
|
||||||
|
for a_m in re.finditer(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', 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'(?<!\[)(?<!\]\()(?<!\])(?<!\()(' + re.escape(link_text) + r')(?!\w)(?!\]\(|\])'
|
||||||
|
replacement = f"[{link_text}](./{fname})"
|
||||||
|
text = re.sub(pattern, replacement, text)
|
||||||
|
except re.error:
|
||||||
|
# Если regex не сработал (нестандартные символы), пропускаем
|
||||||
|
pass
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Парсинг одной страницы ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def parse_page(html_content: str) -> 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'<link\s+rel="canonical"\s+href="[^"]*([^/]+\.html)"', html_content)
|
||||||
|
if not href_base_match:
|
||||||
|
href_base_match = re.search(r'href="([^/]*\.html)"', html_content)
|
||||||
|
|
||||||
|
href_base = ""
|
||||||
|
if href_base_match:
|
||||||
|
href_base = os.path.basename(href_base_match.group(1))
|
||||||
|
elif breadcrumbs:
|
||||||
|
href_base = breadcrumbs[-1][1]
|
||||||
|
if not href_base:
|
||||||
|
href_base = "index.html"
|
||||||
|
|
||||||
|
# Преобразуем ссылки в относительные .md
|
||||||
|
cleaned_body = []
|
||||||
|
for line in body_lines:
|
||||||
|
unescaped = unescape_html(line)
|
||||||
|
converted = convert_links_in_text(unescaped, links)
|
||||||
|
cleaned_body.append(converted)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"breadcrumbs": breadcrumbs,
|
||||||
|
"body_lines": cleaned_body,
|
||||||
|
"images": images,
|
||||||
|
"links": links,
|
||||||
|
"href_base": href_base,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Построение индекса из zoom_pageinfo.js ──────────────────────────────────
|
||||||
|
|
||||||
|
def build_index() -> 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"")
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user