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:
2026-05-26 18:44:38 +03:00
parent 1644ff82e0
commit 28d2ae3868
46 changed files with 3793 additions and 357 deletions
+426
View File
@@ -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-сущности."""
# Сначала &nbsp; и неразрывные пробелы
s = s.replace("&nbsp;", " ")
s = s.replace("&#160;", " ")
s = s.replace("&shy;", "")
s = s.replace("&mdash;", "")
s = s.replace("&ndash;", "")
s = s.replace("&laquo;", "«")
s = s.replace("&raquo;", "»")
s = s.replace("&quot;", '"')
s = s.replace("&#39;", "'")
s = s.replace("&apos;", "'")
s = s.replace("&lt;", "<")
s = s = s.replace("&gt;", ">")
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"![{alt_clean}]({data_uri})\n")
image_count += 1
# Тело страницы
for line in parsed["body_lines"]:
line_unescaped = unescape_html(line)
# Убираем лишние пробелы, но сохраняем структуру
md_parts.append(f"{line_unescaped}\n")
# Добавляем оглавление в начало (после заголовка)
final_md = md_parts[:2] # Заголовок + подзаголовок
final_md.append("## Оглавление\n")
final_md.extend(toc_entries)
final_md.extend(md_parts[2:])
# Записываем файл
output_text = "\n".join(final_md)
OUTPUT_MD.write_text(output_text, encoding="utf-8")
print(f"\n✅ Готово!")
print(f" Обработано страниц: {processed_count}")
print(f" Пропущено (пустые/ошибки): {skipped_count}")
print(f" Сохранено изображений: {image_count}")
file_size = OUTPUT_MD.stat().st_size
print(f" Размер файла: {file_size / 1024 / 1024:.1f} MB")
if __name__ == "__main__":
main()