3 Commits
Author SHA1 Message Date
grigory 9cee071f9f docs: записка о переносе оверлея в 1.6.0 (ours-1.6) 2026-10-05 18:16:44 +03:00
grigory 1cc9043d70 port: наш оверлей 1.5.1 → апстрим 1.6.0 (ветка ours-1.6)
Перенос нашей дельты (10 изменённых + 2 новых файла) с v1.5.1 на тег v1.6.0
трёхсторонним слиянием с ветвью ours-1.5 (v1.5.1 + /opt/data/autocad-mcp-build/patch).
Файлы 1.6.0 не перезаписывались копированием: 30 конфликтов разрешены по смыслу.

Что перенесено (наше, чего в 1.6.0 не было):
- инструменты: titleblock_apply_gost_form3, file_put, file_get,
  drawing_open_base64, drawing_save_base64, entity_create_attdef,
  block_get_attdefs, textstyle_ensure, text_apply_style, text_style_report,
  dimstyle_ensure, layout_set_paper (+ таблица алиасов и регистрация в server.py);
- параметр `layout` (адресация в пространство листа) — 36 инструментов, включая
  геометрию (line/polyline/circle/arc/...), block_insert/block_create_from_entities,
  dimension_* и аннотацию; в COM-бэкенде этому служит _activate_layout();
- `inline_font` (ГОСТ-шрифт ISOCPEUR прямо в содержимое MTEXT/таблиц) — 5 инструментов;
- ГОСТ-стиль по умолчанию (_ensure_default_text_style, DEFAULT_TEXT_STYLE=GOST_A)
  при drawing_new/drawing_open;
- ATTDEF → ATTRIB: block_insert создаёт именованную вставку (не анонимный *U-блок),
  block_set_attributes достраивает отсутствующие ATTRIB из определений блока;
- отчётность block_insert/block_set_attributes: filled / unknown_tags / created_tags
  и штамп стиля на ATTRIB (mark_style, attr_style);
- PDF: page_size («layout» — страница равна формату листа 1:1, «extents» — по
  содержимому), настоящий текстовый слой (pdf.fonttype=42 + отрисовка текста
  поверх геометрии, включая текст через видовые экраны) и предупреждения о
  нечитаемо мелких надписях;
- engineering/gost_titleblock.py (основная надпись по ГОСТ Р 21.1101-2013, форма 3)
  и _fonts/isocpeur.ttf.

Что взято у 1.6.0 (наши дубли заменены, чтобы не спорить с апстримом):
- drawing_export_pdf: рамка листа считается его _paper_frame() (масштаб, plot area,
  поворот, поля) вместо нашей _layout_paper_mm(); наш текстовый слой и page_size
  наложены сверху;
- размерные стили: _current_dimstyle_name() вместо нашего _current_dimstyle()
  (наш хелпер оставлен — им пользуется отчёт о стилях);
- таблицы (ezdxf и COM): объединённая строка-заголовок, «span», масштабируемый
  отступ ячеек и SetCellTextHeight() из 1.6.0 + наш inline_font/style;
- block_insert: структура вставки и обработка многострочных ATTRIB из 1.6.0
  + наша отчётность и штамп стиля;
- экспорт DWG/xref/image_attach/профили, группы arch/pid/mech/sheet/understand —
  сохранены целиком.

Три структурные правки после слияния (git сшил код механически, найдены pyflakes
и аудитом дерева):
- com_backend: наша подпись drawing_export_pdf попала в тело xref_attach и
  дублировала метод; вставка убрана, паритетные text_layer/page_size перенесены
  в настоящую COM-функцию;
- _space_name в ezdxf-бэкенде был занят дважды (нашин и 1.6.0): наш переименован
  в _reply_space_name, иначе он затенял хелпер апстрима;
- entity_create_text: ключ "style" в dxfattribs стоял дважды (наш оставлен).

Прод :30015 не затронут.
2026-10-05 18:12:40 +03:00
grigory 2e2a2b96a9 ours: наш оверлей к апстриму 1.5.1 (дельта для переноса в 1.6.0)
10 изменённых файлов + 2 новых (engineering/gost_titleblock.py, _fonts/isocpeur.ttf).
Ветка-донор для трёхстороннего переноса в ours-1.6.
2026-10-05 18:04:55 +03:00
14 changed files with 3270 additions and 274 deletions
-121
View File
@@ -1,121 +0,0 @@
# AutoCAD MCP Pro — как у нас развёрнут и как обновлять
Этот репозиторий — **зеркало** открытого проекта `U-C4N/Autocad-MCP` (PyPI-пакет
`autocad-mcp-pro`), которым мы пользуемся для черчения из агентов Hermes.
## 1. Источники версий и обновлений (единственные авторитетные)
| Что | Где смотреть |
|---|---|
| Исходный репозиторий | https://github.com/U-C4N/Autocad-MCP |
| Релизы и теги (что нового) | https://github.com/U-C4N/Autocad-MCP/releases — теги `v1.3.0 … v1.6.0` |
| Список изменений | `CHANGELOG.md` в корне репозитория (самый подробный источник) |
| PyPI-пакет и доступные версии | https://pypi.org/pypi/autocad-mcp-pro/json (поле `info.version`, `releases`) |
| Внутренняя версия сервера | MCP-вызов `system_about` / ответ `initialize` (`serverInfo.version`) |
**Текущий пин зеркала:** ветка `main`, коммит `cdb1063` (после релиза **1.6.0 от 24.09.2026**).
Релизы: `v1.5.1` → `v1.6.0` (247 зарегистрированных инструментов, 242 отдаётся по умолчанию).
Обновления проверять так (без установки):
```bash
# последняя версия на PyPI
curl -s https://pypi.org/pypi/autocad-mcp-pro/json | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['info']['version'], list(d['releases'])[-4:])"
# свежие релизы на GitHub
curl -s https://api.github.com/repos/U-C4N/Autocad-MCP/releases | python3 -c "import json,sys;[print(r['tag_name'], r['published_at']) for r in json.load(sys.stdin)[:5]]"
```
## 2. Как это работает у нас (архитектура)
```
Hermes (Docker, VM108) --HTTP MCP (Bearer)--> контейнер autocad-mcp :30015 --ezdxf--> DXF/PDF
контейнер autocad-mcp-160 :30016 (тестовый 1.6.0)
папка обмена: /var/lib/docker/volumes/hermes_hermes-data/_data/dxf → /opt/data/dxf
```
| Параметр | Значение |
|---|---|
| Рабочий контейнер | `autocad-mcp`, образ `autocad-mcp:latest` = **1.5.1 + наш патч**, порт **30015 → 8000** |
| Тестовый контейнер | `autocad-mcp-160`, образ `autocad-mcp:1.6.0` (апстрим без патча), порт **30016** |
| Бэкенд | `AUTOCAD_MCP_BACKEND=ezdxf` (headless, без AutoCAD; COM — только на Windows рядом с AutoCAD) |
| Токен доступа | файл `/opt/data/.autocad_mcp_token` (chmod 600, **в git не попадает**), передаётся как `Authorization: Bearer …` |
| Папка файлов | `ALLOWED_PATHS=/opt/data/dxf` — сервер принимает файлы **только** оттуда (иначе «Path rejected») |
| Профиль инструментов | `TOOL_PROFILE=full`; для слабых моделей есть `TOOL_PACKS=core,mech...` и `DISCOVERY_MODE=search` (2 инструмента / 356 токенов вместо 83 921) |
| Деплой-скрипты | `/opt/data/deploy_autocad_mcp.py` (рабочий, с тегом отката `autocad-mcp:prev`), `/opt/data/work/cad_mcp/deploy_160_test.py` (тестовый 1.6.0) |
| Логи сборки | `/opt/data/autocad_mcp_build.log`, `/opt/data/autocad_mcp_160_build.log` |
| Проверка живости | `python3 /opt/data/work/cad_mcp/tools_inventory.py 30015` (делает `initialize` → `tools/list`) |
## 3. Что в нашей рабочей сборке сверх апстрима (патч 01.10.2026)
Оверлей лежит в `/opt/data/autocad-mcp-build/patch/` и копируется поверх `site-packages`
(см. `Dockerfile` в `/opt/data/autocad-mcp-build/`). Этих инструментов в апстриме нет:
| Инструмент | Зачем |
|---|---|
| `titleblock_apply_gost_form3` | основная надпись по **ГОСТ Р 21.1101-2013 форма 3** (апстрим знает только ISO 7200) |
| `file_put` / `file_get` | передача файлов внутрь `ALLOWED_PATHS` и обратно (без Docker-томов и HTTP-моста) |
| `drawing_open_base64` / `drawing_save_base64` | обмен чертежами в base64 |
| `entity_create_attdef`, `block_get_attdefs` | символы-блоки с атрибутами (тип/высота/группа) |
| параметр `layout=` у инструментов **геометрии** (29 инструментов) | рисовать линии/полилинии прямо на ЛИСТЕ, а не только в модели |
| параметр `inline_font=True` (5 инструментов) | инлайн-кодировка ISOCPEUR в каждом тексте |
| `drawing_export_pdf(page_size="layout")` | страница PDF ровно по формату листа (в 1.5.1 апстрим подгонял по содержимому) |
| `layout_set_paper` | явный размер бумаги листа (A4 книжный и др.) |
| `template_apply_layers(spds)` | ГОСТ-набор слоёв (RAMKA, STAMPS, 00 БЛОКИ АННОТАЦИЙ, Стены) |
| `dimstyle_ensure`, `text_apply_style` | размерный стиль СПДС, текстовые стили |
⚠️ **Следствие:** апстрим 1.6.0 нельзя просто «поставить вместо» — часть наших возможностей
пропадёт (ГОСТ-штамп, file_put/file_get, `layout` у геометрии, `inline_font`). Поэтому 1.6.0
поднимается **рядом** (порт 30016), а перенос патча делается отдельным шагом.
## 4. Установка с нуля
```bash
# 1) образ (Docker-контекст: Dockerfile + patch/)
mkdir -p /opt/data/autocad-mcp-build && cd /opt/data/autocad-mcp-build
# Dockerfile:
# FROM python:3.11-slim
# RUN pip install --no-cache-dir autocad-mcp-pro==1.5.1 matplotlib
# COPY patch/ /usr/local/lib/python3.11/site-packages/
# EXPOSE 8000
# CMD ["autocad-mcp","--transport","http","--host","0.0.0.0","--port","8000"]
# 2) сборка и переустановка контейнера (Portainer API, безопасно: сначала образ, потом замена)
python3 /opt/data/deploy_autocad_mcp.py # рабочий (с тегом отката autocad-mcp:prev)
python3 /opt/data/work/cad_mcp/deploy_160_test.py # тестовый 1.6.0 на :30016
# 3) подключение в Hermes (конфиг защищён, менять через config set)
export PATH="/opt/hermes/.venv/bin:$PATH" HERMES_HOME=/opt/data HOME=/opt/data
hermes config set mcp_servers.autocad.type http
hermes config set mcp_servers.autocad.url http://10.26.5.108:30015/mcp
hermes config set mcp_servers.autocad.headers.Authorization "Bearer <токен из /opt/data/.autocad_mcp_token>"
hermes mcp test autocad # ожидаем "Connected" + число инструментов
# 4) проверка
python3 /opt/data/work/cad_mcp/tools_inventory.py 30015
python3 /opt/skills/devops/cad-automation/scripts/mcp_acceptance_test.py # приёмочный прогон
```
## 5. Обновление версии
1. Проверить новую версию (п. 1) и прочитать `CHANGELOG.md`.
2. Собрать **тестовый** образ на новую версию: скопировать
`/opt/data/autocad-mcp-build/Dockerfile` в новый каталог, заменить пин версии,
запустить `deploy_160_test.py` (порт 30016).
3. Прогнать инвентаризацию и приёмку по обоим портам, сравнить:
`tools_inventory.py 30015` против `tools_inventory.py 30016` — что появилось, что пропало.
4. Перенести наш патч (см. п. 3) либо решить, что из апстрима заменяет нашу доработку.
5. Переключить рабочий контейнер (`deploy_autocad_mcp.py`) — он сам ставит тег отката
`autocad-mcp:prev`; при проблеме вернуться на него.
**Откат:** остановить контейнер, создать заново из образа `autocad-mcp:prev` с теми же
параметрами (порт 30015 → 8000, `ALLOWED_PATHS=/opt/data/dxf`, тот же токен, User 10000:10000).
## 6. Грабли, которые уже собирали (не повторять)
- `tools/list` «сырым» POST даёт **HTTP 400** — нужен `initialize` → заголовок `Mcp-Session-Id` → `tools/list`.
- Файлы внутрь контейнера: только путь из `ALLOWED_PATHS` (`/opt/data/dxf`), иначе «Path rejected».
- У контейнера **свой том** — это не тот `/opt/data/dxf`, который видит агент; для передачи
файлов используем `file_put`/`file_get` или мост `dxf-bridge` (`http://10.26.5.108:30055/`).
- PDF: проверять медиабокс (`pymupdf` → `page.rect.width/72*25.4` ≈ формат листа) и шрифт (`ISOCPEUR`).
- Чертёж отдавать пользователю только после проверки движком AutoCAD (`accoreconsole /i … /s …`).
- COM-режим (живой AutoCAD) **невозможен из Docker** — сервер обязан работать на Windows рядом с AutoCAD.
Binary file not shown.
+278 -25
View File
@@ -412,6 +412,35 @@ def _block_names(doc) -> set[str]:
return names return names
def _activate_layout(name: str | None) -> None:
"""Make the named layout the active tab, so the next COM call draws on it.
The ezdxf backend takes ``layout`` as a per-call target; COM has one active
layout per document, so the equivalent is the same switch
``layout_set_current`` performs — and it stays switched, because the sheet is
where the drafter carries on. Unknown names are refused instead of silently
drawing into model space.
"""
wanted = str(name or "").strip()
if not wanted:
return
doc = _acad_doc()
try:
current = str(doc.ActiveLayout.Name)
except Exception as exc: # a probe must never block a draw
log.debug("ActiveLayout.Name unavailable (%s)", exc)
current = ""
if current and current.lower() == wanted.lower():
return
try:
doc.ActiveLayout = doc.Layouts.Item(wanted)
except Exception as exc:
raise RuntimeError(
f"layout {wanted!r} does not exist: create it with layout_create first"
) from exc
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
#: `drawing_properties_*` field → `IAcadSummaryInfo` property. #: `drawing_properties_*` field → `IAcadSummaryInfo` property.
@@ -2199,7 +2228,19 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync) return await self._run(_sync)
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: async def drawing_export_pdf(
self,
path: str,
layout: str | None = None,
text_layer: bool | None = None,
page_size: str | None = None,
) -> dict:
# ``text_layer`` and ``page_size`` are accepted for signature parity with
# the ezdxf backend: AutoCAD plots real text natively, and PlotToFile
# plots the layout's own paper at its own scale (that is what a .pc3
# device does), so neither flag has anything to switch here.
del text_layer
del page_size
"""Plot one layout to PDF through ``Plot.PlotToFile`` and confirm the file. """Plot one layout to PDF through ``Plot.PlotToFile`` and confirm the file.
``layout=None`` means model space, as the tool advertises — the Model ``layout=None`` means model space, as the tool advertises — the Model
@@ -4677,8 +4718,12 @@ class ComBackend(AutoCADBackend):
layer=None, layer=None,
color=None, color=None,
linetype=None, linetype=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
# Layout ровно как у ezdxf: COM рисует в активный лист, поэтому
# вызов сначала переключает вкладку (см. _activate_layout).
_activate_layout(layout)
mspace = _msp() mspace = _msp()
line = mspace.AddLine(_apoint(x1, y1, z1), _apoint(x2, y2, z2)) line = mspace.AddLine(_apoint(x1, y1, z1), _apoint(x2, y2, z2))
_apply_entity_attrs(line, layer, color, linetype) _apply_entity_attrs(line, layer, color, linetype)
@@ -4694,8 +4739,10 @@ class ComBackend(AutoCADBackend):
radius, radius,
layer=None, layer=None,
color=None, color=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
circle = mspace.AddCircle(_apoint(cx, cy), float(radius)) circle = mspace.AddCircle(_apoint(cx, cy), float(radius))
_apply_entity_attrs(circle, layer, color, None) _apply_entity_attrs(circle, layer, color, None)
@@ -4713,8 +4760,10 @@ class ComBackend(AutoCADBackend):
end_angle, end_angle,
layer=None, layer=None,
color=None, color=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
arc = mspace.AddArc( arc = mspace.AddArc(
_apoint(cx, cy), _apoint(cx, cy),
@@ -4734,8 +4783,10 @@ class ComBackend(AutoCADBackend):
closed=False, closed=False,
layer=None, layer=None,
color=None, color=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
flat = [] flat = []
for pt in points: for pt in points:
@@ -4757,11 +4808,25 @@ class ComBackend(AutoCADBackend):
rotation=0.0, rotation=0.0,
layer=None, layer=None,
color=None, color=None,
style=None,
layout=None,
inline_font=True,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
# Layout ровно как у ezdxf: COM рисует в активный лист, поэтому
# вызов сначала переключает вкладку (см. _activate_layout).
_activate_layout(layout)
mspace = _msp() mspace = _msp()
txt = mspace.AddText(text, _apoint(x, y), float(height)) txt = mspace.AddText(text, _apoint(x, y), float(height))
txt.Rotation = deg2rad(rotation) txt.Rotation = deg2rad(rotation)
if style:
# AutoCAD refuses an unknown style name outright; a caller who
# asked for a face the drawing does not carry gets the current
# style instead of a failed call (matching ezdxf's fallback).
try:
txt.StyleName = str(style)
except Exception:
pass
_apply_entity_attrs(txt, layer, color, None) _apply_entity_attrs(txt, layer, color, None)
_regen() _regen()
return _entity_info(txt) return _entity_info(txt)
@@ -4778,13 +4843,26 @@ class ComBackend(AutoCADBackend):
rotation=0.0, rotation=0.0,
layer=None, layer=None,
color=None, color=None,
style=None,
layout=None,
inline_font=True,
) -> EntityInfo: ) -> EntityInfo:
from engineering.annotation import wrap_inline_font
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
mt = mspace.AddMText(_apoint(x, y), float(width), text) mt = mspace.AddMText(
_apoint(x, y), float(width), wrap_inline_font(text, inline_font)
)
mt.Height = float(height) mt.Height = float(height)
if rotation: if rotation:
mt.Rotation = deg2rad(rotation) # NEW-mtext-1: honor caller rotation mt.Rotation = deg2rad(rotation) # NEW-mtext-1: honor caller rotation
if style:
try:
mt.StyleName = str(style)
except Exception:
pass
_apply_entity_attrs(mt, layer, color, None) _apply_entity_attrs(mt, layer, color, None)
_regen() _regen()
return _entity_info(mt) return _entity_info(mt)
@@ -4802,31 +4880,44 @@ class ComBackend(AutoCADBackend):
text_height=2.5, text_height=2.5,
title=None, title=None,
layer="TEXT", layer="TEXT",
style=None,
layout=None,
inline_font=True,
) -> EntityInfo: ) -> EntityInfo:
from engineering.annotation import prepare_table_layout from engineering.annotation import prepare_table_layout, wrap_inline_font
layout = prepare_table_layout(rows, headers, column_widths, row_height, text_height, title) table_layout = prepare_table_layout(
rows, headers, column_widths, row_height, text_height, title
)
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
table = mspace.AddTable( table = mspace.AddTable(
_apoint(x, y), _apoint(x, y),
layout.row_count, table_layout.row_count,
layout.column_count, table_layout.column_count,
layout.row_height, table_layout.row_height,
layout.column_widths[0], table_layout.column_widths[0],
) )
for column, width in enumerate(layout.column_widths): for column, width in enumerate(table_layout.column_widths):
table.SetColumnWidth(column, width) table.SetColumnWidth(column, width)
# MEASURED on AutoCAD 2026: IAcadTable has no TextHeight property # MEASURED on AutoCAD 2026: IAcadTable has no TextHeight property
# (the old `table.TextHeight = h` raised and was swallowed, leaving # (the old `table.TextHeight = h` raised and was swallowed, leaving
# the Standard style's 6.0 / 4.5 - illegible at 1:50). The member # the Standard style's 6.0 / 4.5 - illegible at 1:50). The member
# is SetCellTextHeight(row, col, h), set per cell. # is SetCellTextHeight(row, col, h), set per cell.
for row_index, row in enumerate(layout.cells): for row_index, row in enumerate(table_layout.cells):
table.SetRowHeight(row_index, layout.row_height) table.SetRowHeight(row_index, table_layout.row_height)
for column_index, value in enumerate(row): for column_index, value in enumerate(row):
table.SetText(row_index, column_index, value) table.SetText(row_index, column_index, wrap_inline_font(value, inline_font))
table.SetCellTextHeight(row_index, column_index, layout.text_height) table.SetCellTextHeight(row_index, column_index, table_layout.text_height)
cell_style = None
if style:
try:
table.TextStyle = str(style)
cell_style = str(style)
except Exception as exc:
log.debug("setting table TextStyle failed: %s", exc)
_apply_entity_attrs(table, layer, None, None) _apply_entity_attrs(table, layer, None, None)
_regen() _regen()
info = _entity_info(table) info = _entity_info(table)
@@ -4837,11 +4928,13 @@ class ComBackend(AutoCADBackend):
"representation": "native", "representation": "native",
"logical_group_id": f"table:{uuid.uuid4().hex}", "logical_group_id": f"table:{uuid.uuid4().hex}",
"child_handles": [info.handle], "child_handles": [info.handle],
"rows": layout.row_count, "rows": table_layout.row_count,
"columns": layout.column_count, "columns": table_layout.column_count,
"text_style": cell_style,
"layout": str(layout or ""),
"bounds": { "bounds": {
"min": [float(x), float(y) - layout.height], "min": [float(x), float(y) - table_layout.height],
"max": [float(x) + layout.width, float(y)], "max": [float(x) + table_layout.width, float(y)],
}, },
} }
) )
@@ -4857,8 +4950,10 @@ class ComBackend(AutoCADBackend):
landing_gap=1.0, landing_gap=1.0,
arrow_size=2.5, arrow_size=2.5,
layer="DIM", layer="DIM",
layout=None,
inline_font=True,
) -> EntityInfo: ) -> EntityInfo:
from engineering.annotation import validate_mleader from engineering.annotation import validate_mleader, wrap_inline_font
normalized = validate_mleader(points, text) normalized = validate_mleader(points, text)
if text_height <= 0 or landing_gap < 0 or arrow_size <= 0: if text_height <= 0 or landing_gap < 0 or arrow_size <= 0:
@@ -4867,11 +4962,12 @@ class ComBackend(AutoCADBackend):
) )
def _sync(): def _sync():
_activate_layout(layout)
flat = [] flat = []
for px, py in normalized: for px, py in normalized:
flat.extend([px, py, 0.0]) flat.extend([px, py, 0.0])
leader = _msp().AddMLeader(_av(flat), 0) leader = _msp().AddMLeader(_av(flat), 0)
leader.TextString = str(text) leader.TextString = wrap_inline_font(text, inline_font)
leader.TextHeight = float(text_height) leader.TextHeight = float(text_height)
leader.LandingGap = float(landing_gap) leader.LandingGap = float(landing_gap)
leader.ArrowheadSize = float(arrow_size) leader.ArrowheadSize = float(arrow_size)
@@ -4905,8 +5001,10 @@ class ComBackend(AutoCADBackend):
angle=0.0, angle=0.0,
layer=None, layer=None,
color=None, color=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
# acPatternTypePreDefined = 0 # acPatternTypePreDefined = 0
hatch = mspace.AddHatch(0, pattern, False) hatch = mspace.AddHatch(0, pattern, False)
@@ -4933,8 +5031,10 @@ class ComBackend(AutoCADBackend):
fit_points, fit_points,
layer=None, layer=None,
color=None, color=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
flat = [] flat = []
for pt in fit_points: for pt in fit_points:
@@ -4959,8 +5059,10 @@ class ComBackend(AutoCADBackend):
ratio=0.5, ratio=0.5,
layer=None, layer=None,
color=None, color=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
ellipse = mspace.AddEllipse( ellipse = mspace.AddEllipse(
_apoint(cx, cy), _apoint(cx, cy),
@@ -4979,8 +5081,10 @@ class ComBackend(AutoCADBackend):
y, y,
layer=None, layer=None,
color=None, color=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
pt = mspace.AddPoint(_apoint(x, y)) pt = mspace.AddPoint(_apoint(x, y))
_apply_entity_attrs(pt, layer, color, None) _apply_entity_attrs(pt, layer, color, None)
@@ -4998,9 +5102,11 @@ class ComBackend(AutoCADBackend):
scale_y=1.0, scale_y=1.0,
rotation=0.0, rotation=0.0,
layer=None, layer=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_require_block_defined(_acad_doc(), name) _require_block_defined(_acad_doc(), name)
_activate_layout(layout)
mspace = _msp() mspace = _msp()
ref = mspace.InsertBlock( ref = mspace.InsertBlock(
_apoint(x, y), _apoint(x, y),
@@ -5033,8 +5139,10 @@ class ComBackend(AutoCADBackend):
tol_lower=None, tol_lower=None,
tol_mode="none", tol_mode="none",
text_override=None, text_override=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
# The ActiveX API has no AddDimLinear — rotated linear dims are # The ActiveX API has no AddDimLinear — rotated linear dims are
# created with AddDimRotated (GH issue #3). # created with AddDimRotated (GH issue #3).
@@ -5061,8 +5169,10 @@ class ComBackend(AutoCADBackend):
dim_x, dim_x,
dim_y, dim_y,
layer=None, layer=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
dim = mspace.AddDimAligned(_apoint(x1, y1), _apoint(x2, y2), _apoint(dim_x, dim_y)) dim = mspace.AddDimAligned(_apoint(x1, y1), _apoint(x2, y2), _apoint(dim_x, dim_y))
if layer: if layer:
@@ -5083,8 +5193,10 @@ class ComBackend(AutoCADBackend):
tx, tx,
ty, ty,
layer=None, layer=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
dim = mspace.AddDimAngular( dim = mspace.AddDimAngular(
_apoint(vx, vy), _apoint(vx, vy),
@@ -5111,8 +5223,10 @@ class ComBackend(AutoCADBackend):
tol_lower=None, tol_lower=None,
tol_mode="none", tol_mode="none",
text_override=None, text_override=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
dim = mspace.AddDimRadial( dim = mspace.AddDimRadial(
_apoint(cx, cy), _apoint(chord_x, chord_y), float(leader_length) _apoint(cx, cy), _apoint(chord_x, chord_y), float(leader_length)
@@ -5137,8 +5251,10 @@ class ComBackend(AutoCADBackend):
tol_lower=None, tol_lower=None,
tol_mode="none", tol_mode="none",
text_override=None, text_override=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
def _sync(): def _sync():
_activate_layout(layout)
mspace = _msp() mspace = _msp()
dim = mspace.AddDimDiametric(_apoint(x1, y1), _apoint(x2, y2), float(leader_length)) dim = mspace.AddDimDiametric(_apoint(x1, y1), _apoint(x2, y2), float(leader_length))
if layer: if layer:
@@ -5149,6 +5265,73 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync) return await self._run(_sync)
async def dimstyle_ensure(
self,
name="СПДС",
text_style=None,
base=None,
set_current=True,
text_height=2.5,
arrow_size=2.5,
) -> dict:
"""Создать размерный стиль (по умолчанию СПДС) и/или сделать его текущим.
COM-эквивалент метода ezdxf: текстовый стиль размера переводим на
ГОСТ-начертание (``text_style``), высоту текста и стрелки задаём явно
(ГОСТ 2.304: 2.5 мм при 1:1), при ``set_current`` стиль становится
активным (``ActiveDimStyle``) — следующий размер выйдет им.
"""
def _sync():
doc = _acad_doc()
style_name = str(name or "").strip()
if not style_name:
raise RuntimeError("dimstyle_ensure: name must not be empty")
names = [str(doc.DimStyles.Item(i).Name) for i in range(doc.DimStyles.Count)]
created = False
if style_name in names:
style = doc.DimStyles.Item(style_name)
else:
style = doc.DimStyles.Add(style_name)
created = True
source = str(base or "").strip() or "Standard"
if source in names:
try:
style.CopyFrom(doc.DimStyles.Item(source))
except Exception as exc:
log.debug("dimstyle_ensure: CopyFrom %s failed (%s)", source, exc)
for attr, value in (("TextStyle", text_style), ("TextHeight", text_height),
("ArrowheadSize", arrow_size)):
if value in (None, "", 0):
continue
try:
setattr(style, attr, value)
except Exception as exc:
# AutoCAD отказывает на незнакомое имя стиля — не валим вызов.
log.debug("dimstyle_ensure: %s=%s rejected (%s)", attr, value, exc)
if set_current:
try:
doc.ActiveDimStyle = style
except Exception as exc:
log.debug("dimstyle_ensure: ActiveDimStyle failed (%s)", exc)
_regen()
current = ""
try:
current = str(doc.ActiveDimStyle.Name)
except Exception:
current = ""
return {
"ok": True,
"name": style_name,
"created": created,
"text_style": str(text_style or ""),
"set_current": bool(set_current),
"current": current,
"dimstyles": names + ([] if style_name in names else [style_name]),
}
return await self._run(_sync)
# ── entity modification ─────────────────────────────────────────────────── # ── entity modification ───────────────────────────────────────────────────
async def entity_move(self, handle, dx, dy, dz=0.0) -> dict: async def entity_move(self, handle, dx, dy, dz=0.0) -> dict:
@@ -5746,6 +5929,8 @@ class ComBackend(AutoCADBackend):
rotation=0.0, rotation=0.0,
attributes=None, attributes=None,
layer=None, layer=None,
style=None,
layout=None,
) -> EntityInfo: ) -> EntityInfo:
from backends.block_specs import validate_attribute_values from backends.block_specs import validate_attribute_values
@@ -5753,6 +5938,7 @@ class ComBackend(AutoCADBackend):
def _sync(): def _sync():
_require_block_defined(_acad_doc(), name) _require_block_defined(_acad_doc(), name)
_activate_layout(layout)
mspace = _msp() mspace = _msp()
ref = mspace.InsertBlock( ref = mspace.InsertBlock(
_apoint(x, y), _apoint(x, y),
@@ -5773,6 +5959,15 @@ class ComBackend(AutoCADBackend):
attr.TextString = values[tag] attr.TextString = values[tag]
except Exception as exc: except Exception as exc:
log.debug("Block insert: GetAttributes or attribute setting failed: %s", exc) log.debug("Block insert: GetAttributes or attribute setting failed: %s", exc)
if style:
# The ATTRIB inherits its ATTDEF's text style; a definition made
# before the ГОСТ style existed carries ``Standard``, which plots
# the block marks in the wrong face.
try:
for attr in ref.GetAttributes():
attr.StyleName = str(style)
except Exception as exc:
log.debug("Block insert: setting attribute style failed: %s", exc)
return _entity_info(ref) return _entity_info(ref)
return await self._run(_sync) return await self._run(_sync)
@@ -6165,7 +6360,7 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync) return await self._run(_sync)
async def block_set_attributes(self, handle, attributes) -> dict: async def block_set_attributes(self, handle, attributes, style=None) -> dict:
from backends.block_specs import validate_attribute_values from backends.block_specs import validate_attribute_values
values = validate_attribute_values(attributes) values = validate_attribute_values(attributes)
@@ -6175,12 +6370,26 @@ class ComBackend(AutoCADBackend):
ref = doc.HandleToObject(handle) ref = doc.HandleToObject(handle)
attrs = ref.GetAttributes() attrs = ref.GetAttributes()
updated = [] updated = []
restyled = 0
for attr in attrs: for attr in attrs:
tag = attr.TagString tag = attr.TagString
if tag in values: if tag in values:
attr.TextString = values[tag] attr.TextString = values[tag]
updated.append(tag) updated.append(tag)
return {"ok": True, "updated_tags": updated} if style:
# The reference's ATTDEF inherited ``Standard`` when there was
# no ГОСТ style yet; a value written into it would still plot
# in the wrong face, so the update carries the style with it.
try:
attr.StyleName = str(style)
restyled += 1
except Exception:
pass
result = {"ok": True, "updated_tags": updated}
if style:
result["style"] = str(style)
result["restyled_tags"] = restyled
return result
return await self._run(_sync) return await self._run(_sync)
@@ -6259,26 +6468,70 @@ class ComBackend(AutoCADBackend):
handles, handles,
base_x=0.0, base_x=0.0,
base_y=0.0, base_y=0.0,
layout=None,
) -> dict: ) -> dict:
"""Build a block definition from entities already in the drawing. """Build a block definition from entities already in the drawing.
This used to refuse and point at ``system_run_command`` with ``_BLOCK``, This used to refuse and point at ``system_run_command`` with ``_BLOCK``,
pushing callers through the free-text escape hatch for something ActiveX pushing callers through the free-text escape hatch for something ActiveX
does directly: ``Blocks.Add`` creates the definition and does directly: ``Blocks.Add`` creates the definition and
``Document.CopyObjects`` puts entities into it. The originals stay in ``Document.CopyObjects`` puts entities into it. The originals stay where
model space, matching the headless backend rather than AutoCAD's BLOCK they were, matching the headless backend rather than AutoCAD's BLOCK
command (which consumes them). command (which consumes them).
``layout`` matches the headless backend: a definition is document-global
(it has no owner sheet), so the parameter constrains the *source* — only
handles whose ``OwnerID`` is that layout's own block are taken, and a
foreign handle is named in a refusal instead of copied silently.
""" """
def _sync(): def _sync():
doc = _acad_doc() doc = _acad_doc()
objects, skipped = [], [] owner_id = None
space_label = ""
if layout is not None:
wanted = self._layout_name(layout)
if wanted.lower() == "model":
space_label = "Model"
owner_id = int(doc.ModelSpace.ObjectID)
else:
resolved = self._find_layout(doc, wanted)
if resolved is None:
raise RuntimeError(
f"layout {wanted!r} does not exist: create it with "
f"layout_create first"
)
space_label = resolved
owner_id = int(doc.Layouts.Item(resolved).Block.ObjectID)
objects, skipped, foreign = [], [], []
for handle in handles: for handle in handles:
try: try:
objects.append(doc.HandleToObject(str(handle))) obj = doc.HandleToObject(str(handle))
except Exception as exc: except Exception as exc:
log.debug("resolving %s for block %s: %s", handle, name, exc) log.debug("resolving %s for block %s: %s", handle, name, exc)
skipped.append(str(handle)) skipped.append(str(handle))
continue
if owner_id is not None:
# An unreadable OwnerID is refused with the foreign handles
# rather than waved through: the point of layout= is to
# guarantee the definition came off that sheet.
try:
same_space = int(obj.OwnerID) == owner_id
except Exception as exc:
log.debug("OwnerID of %s unreadable (%s)", handle, exc)
same_space = False
if not same_space:
foreign.append(str(handle))
continue
objects.append(obj)
if foreign:
raise RuntimeError(
f"block_create_from_entities: handle(s) {', '.join(foreign)} are not in "
f"space {space_label!r}; a definition is built from one sheet's entities "
f"at a time. Drop layout= to collect drawing-wide, or pass the handles "
f"of that sheet. No definition was created."
)
# Resolve before creating, so a call where every handle was a typo # Resolve before creating, so a call where every handle was a typo
# does not leave an empty definition behind. # does not leave an empty definition behind.
if not objects: if not objects:
+5
View File
@@ -30,6 +30,7 @@ class DimensionContract(ABC):
tol_lower: float | None = None, tol_lower: float | None = None,
tol_mode: str = "none", tol_mode: str = "none",
text_override: str | None = None, text_override: str | None = None,
layout: str | None = None,
) -> EntityInfo: ... ) -> EntityInfo: ...
@abstractmethod @abstractmethod
@@ -42,6 +43,7 @@ class DimensionContract(ABC):
dim_x: float, dim_x: float,
dim_y: float, dim_y: float,
layer: str | None = None, layer: str | None = None,
layout: str | None = None,
) -> EntityInfo: ... ) -> EntityInfo: ...
@abstractmethod @abstractmethod
@@ -56,6 +58,7 @@ class DimensionContract(ABC):
tx: float, tx: float,
ty: float, ty: float,
layer: str | None = None, layer: str | None = None,
layout: str | None = None,
) -> EntityInfo: ... ) -> EntityInfo: ...
@abstractmethod @abstractmethod
@@ -71,6 +74,7 @@ class DimensionContract(ABC):
tol_lower: float | None = None, tol_lower: float | None = None,
tol_mode: str = "none", tol_mode: str = "none",
text_override: str | None = None, text_override: str | None = None,
layout: str | None = None,
) -> EntityInfo: ... ) -> EntityInfo: ...
@abstractmethod @abstractmethod
@@ -86,4 +90,5 @@ class DimensionContract(ABC):
tol_lower: float | None = None, tol_lower: float | None = None,
tol_mode: str = "none", tol_mode: str = "none",
text_override: str | None = None, text_override: str | None = None,
layout: str | None = None,
) -> EntityInfo: ... ) -> EntityInfo: ...
+11
View File
@@ -72,6 +72,9 @@ class EntityCreationContract(ABC):
rotation: float = 0.0, rotation: float = 0.0,
layer: str | None = None, layer: str | None = None,
color: int | None = None, color: int | None = None,
style: str | None = None,
layout: str | None = None,
inline_font: bool = True,
) -> EntityInfo: ... ) -> EntityInfo: ...
@abstractmethod @abstractmethod
@@ -85,6 +88,9 @@ class EntityCreationContract(ABC):
rotation: float = 0.0, rotation: float = 0.0,
layer: str | None = None, layer: str | None = None,
color: int | None = None, color: int | None = None,
style: str | None = None,
layout: str | None = None,
inline_font: bool = True,
) -> EntityInfo: ... ) -> EntityInfo: ...
@abstractmethod @abstractmethod
@@ -99,6 +105,9 @@ class EntityCreationContract(ABC):
text_height: float = 2.5, text_height: float = 2.5,
title: str | None = None, title: str | None = None,
layer: str = "TEXT", layer: str = "TEXT",
style: str | None = None,
layout: str | None = None,
inline_font: bool = True,
) -> EntityInfo: ... ) -> EntityInfo: ...
@abstractmethod @abstractmethod
@@ -110,6 +119,8 @@ class EntityCreationContract(ABC):
landing_gap: float = 1.0, landing_gap: float = 1.0,
arrow_size: float = 2.5, arrow_size: float = 2.5,
layer: str = "DIM", layer: str = "DIM",
layout: str | None = None,
inline_font: bool = True,
) -> EntityInfo: ... ) -> EntityInfo: ...
@abstractmethod @abstractmethod
+23
View File
@@ -28,6 +28,29 @@ class LayoutContract(ABC):
"""Activate a layout tab ('Model' or a paper-space layout name).""" """Activate a layout tab ('Model' or a paper-space layout name)."""
... ...
# MCP card t_b950b5c4 (01.10.2026). A sheet's format is part of the page
# setup, and every plot of the sheet reads it: without a way to state the
# format, a 210x297 frame drawn on a layout that still says A3 exports onto
# A3 with the frame in a corner. The headless backend stores it in the
# layout's paper fields; ActiveX exposes no verified equivalent.
@capability( # noqa: B027
"paper_space",
reason=(
"ActiveX has no verified member for a layout's sheet format (the page setup lives in "
"the drawing's plot configuration, which has not been exercised against a live "
"AutoCAD). Set the format in AutoCAD's Page Setup, or use the headless backend "
"(AUTOCAD_MCP_BACKEND=ezdxf), where layout_create + layout_set_paper store it."
),
)
async def layout_set_paper(self, name: str, width: float, height: float) -> dict:
"""Set the sheet a layout is set up for, in millimetres.
Returns ``{ok, layout, paper_mm}``. This is what
``drawing_export_pdf(page_size='layout')`` reads to size the page, so a
sheet exported at 1:1 is the format it was drawn for.
"""
...
@abstractmethod @abstractmethod
async def layout_delete(self, name: str) -> dict: async def layout_delete(self, name: str) -> dict:
"""Delete a paper-space layout and everything drawn on it. """Delete a paper-space layout and everything drawn on it.
+8 -1
View File
@@ -622,11 +622,14 @@ class PremiumContract(ABC):
style: DimStyle = "chain", style: DimStyle = "chain",
offset: float = 10.0, offset: float = 10.0,
layer: str | None = None, layer: str | None = None,
layout: str | None = None,
) -> list[EntityInfo]: ) -> list[EntityInfo]:
"""Generate ISO 129 dimensions across `handles` in the chosen style """Generate ISO 129 dimensions across `handles` in the chosen style
(chain | baseline | ordinate). `offset` is the dimension-line (chain | baseline | ordinate). `offset` is the dimension-line
distance from the reference geometry (mm). `layer` defaults to the distance from the reference geometry (mm). `layer` defaults to the
active layer set's dimension layer (DIM, or M-DIMEN-T-N for iso13567).""" active layer set's dimension layer (DIM, or M-DIMEN-T-N for iso13567).
`layout` puts the dimensions on a named paper-space sheet instead of the
current space, so a sheet's dimensions live in paper millimetres."""
if not handles: if not handles:
return [] return []
dim_layer = layer or self._role_layer("dim") dim_layer = layer or self._role_layer("dim")
@@ -668,6 +671,7 @@ class PremiumContract(ABC):
my + ny * off, my + ny * off,
rotation=math.degrees(math.atan2(dy, dx)), rotation=math.degrees(math.atan2(dy, dx)),
layer=dim_layer, layer=dim_layer,
layout=layout,
) )
results.append(dim) results.append(dim)
@@ -689,6 +693,7 @@ class PremiumContract(ABC):
# baseline, not horizontal. # baseline, not horizontal.
rotation=base_rot, rotation=base_rot,
layer=dim_layer, layer=dim_layer,
layout=layout,
) )
results.append(dim) results.append(dim)
@@ -704,6 +709,7 @@ class PremiumContract(ABC):
sx + (ex - sx) / 2.0, sx + (ex - sx) / 2.0,
max(sy, ey) + rung, max(sy, ey) + rung,
layer=dim_layer, layer=dim_layer,
layout=layout,
) )
results.append(dim) results.append(dim)
if abs(ey - sy) > 1e-9: if abs(ey - sy) > 1e-9:
@@ -716,6 +722,7 @@ class PremiumContract(ABC):
(sy + ey) / 2.0, (sy + ey) / 2.0,
rotation=90.0, rotation=90.0,
layer=dim_layer, layer=dim_layer,
layout=layout,
) )
results.append(dim) results.append(dim)
+1456 -68
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -1246,6 +1246,9 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"standard layers", "standard layers",
"architectural layers", "architectural layers",
"mechanical layers", "mechanical layers",
"spds layers",
"gost layers",
"слои спдс",
"translate the layers", "translate the layers",
), ),
), ),
@@ -2577,6 +2580,39 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"ведомость кабелей", "ведомость кабелей",
), ),
), ),
"text_apply_style": ToolAliases(
acad=("STYLE", "TEXTSTYLE", "-STYLE"),
synonyms=(
"text style",
"drawing font",
"менять шрифт",
"isocpeur",
"gost font",
"all text in one font",
"uniform lettering",
),
),
"text_style_report": ToolAliases(
acad=("STYLE",),
synonyms=(
"which font is used",
"text style report",
"check the font",
"audit lettering",
"is every label isocpeur",
),
),
"dimstyle_ensure": ToolAliases(
acad=("DIMSTYLE", "-DIMSTYLE"),
synonyms=(
"dimension style",
"spds dimstyle",
"make spds current",
"размерный стиль",
"dimension text style",
),
),
} }
+62
View File
@@ -0,0 +1,62 @@
# ours-1.6 — наш оверлей поверх апстрима 1.6.0
Ветка `ours-1.6` = тег **v1.6.0** (`cdb1063`, released 1.6.0 lineage) + наш патч,
ранее живший в 1.5.1 (`/opt/data/autocad-mcp-build/patch/`, прод-контейнер `:30015`).
Перенос сделан **трёхсторонним слиянием с ветвью `ours-1.5`** (v1.5.1 + оверлей),
а не копированием файлов: 30 конфликтов разрешены по смыслу. Копирование файлов
оверлея 1.5.1 поверх 1.6.0 затирало бы новые модули 1.6.0 — этот путь исключён.
Дельта к 1.5.1: 10 изменённых + 2 новых файла, +3207 / −152 строк.
## Что перенесено (наше)
| Область | Что именно |
|---|---|
| Инструменты | `titleblock_apply_gost_form3`, `file_put`, `file_get`, `drawing_open_base64`, `drawing_save_base64`, `entity_create_attdef`, `block_get_attdefs`, `textstyle_ensure`, `text_apply_style`, `text_style_report`, `dimstyle_ensure`, `layout_set_paper` |
| Параметр `layout` | адресация в пространство листа — 36 инструментов, включая геометрию (`entity_create_line/polyline/circle/arc`), `block_insert`, `block_create_from_entities`, `dimension_*`; в COM — `_activate_layout()` |
| `inline_font` | ГОСТ-шрифт ISOCPEUR прямо в MTEXT/таблицы — 5 инструментов |
| ГОСТ-стиль | стиль по умолчанию `GOST_A` при `drawing_new` / `drawing_open` |
| Атрибуты | ATTDEF → ATTRIB: `block_insert` вставляет **именованный** блок (не `*U`), `block_set_attributes` достраивает отсутствующие ATTRIB из определений |
| Отчётность | `filled` / `unknown_tags` / `created_tags`, штамп стиля на ATTRIB |
| PDF | `page_size` (`layout` — страница равна формату листа 1:1, `extents` — по содержимому), настоящий текстовый слой (`pdf.fonttype=42`), текст через видовые экраны, предупреждения о мелких надписях |
| Новые файлы | `engineering/gost_titleblock.py` (ГОСТ Р 21.1101-2013, форма 3), `_fonts/isocpeur.ttf` |
## Что уступили апстриму (дубли удалены)
- `drawing_export_pdf`: рамка листа считается `_paper_frame()` из 1.6.0 (масштаб,
plot area, поворот, поля) вместо нашего `_layout_paper_mm()`; наш текстовый слой
и `page_size` наложены сверху.
- Размерные стили: `_current_dimstyle_name()` из 1.6.0; наш `_current_dimstyle()`
оставлен — им пользуется отчёт о стилях.
- Таблицы (ezdxf и COM): объединённая строка-заголовок, «span», масштабируемый
отступ ячеек и `SetCellTextHeight()` из 1.6.0 + наши `inline_font` / style.
- `block_insert`: структура вставки и многострочные ATTRIB из 1.6.0 + наша отчётность.
- Экспорт DWG/xref/image_attach/профили и группы `arch` (12), `pid` (9), `mech` (6),
`sheet`, `understand` — сохранены целиком, 1.6.0 ничего не потерял.
## Три структурные правки после слияния
Найдены pyflakes и аудитом дерева (git сшил код механически):
1. `com_backend`: наша подпись `drawing_export_pdf` попала в тело `xref_attach` и
дублировала метод — вставка убрана, `text_layer`/`page_size` перенесены в
настоящую COM-функцию;
2. `_space_name` в ezdxf-бэкенде был занят дважды — наш переименован в
`_reply_space_name` (иначе затенял хелпер апстрима);
3. `entity_create_text`: ключ `"style"` в `dxfattribs` стоял дважды — оставлен наш
(разрешённый `resolved_style`).
Итого pyflakes: 8 предупреждений — столько же, сколько в чистом 1.6.0, новых нет.
## Проверка
Образ `autocad-mcp:1.6.0-ours`, контейнер `autocad-mcp-160ours`, порт **30041**
(`:30015` — прод, `:30040` — чистый апстрим 1.6.0; оба не тронуты).
- `tools_inventory.py 30041` → **254** инструмента (апстрим даёт 242), все наши
доработки на месте, `layout` у 36, `inline_font` у 5;
- `mcp_acceptance_test.py --base-url http://10.26.5.108:30041/mcp` → **15/15 PASS**;
- живой вызов 1.6.0-новинки `drawing_understand` → ok (entities, units, extents,
clusters, rooms, blocks, layouts, title_block);
- прод `:30015` → 161 инструмент, не тронут.
+7
View File
@@ -45,6 +45,11 @@ from .preflight import (
preflight_drawing, preflight_drawing,
) )
from .refiner import RefineResult, RefineRound, RepairAction, refine_drawing from .refiner import RefineResult, RefineRound, RepairAction, refine_drawing
from .gost_titleblock import (
GostTitleBlockMetadata,
apply_gost_form3_titleblock,
)
from .sheet.frames import draw_sheet_frame, frame_metrics, frame_prims from .sheet.frames import draw_sheet_frame, frame_metrics, frame_prims
from .sheet.titleblock import apply_titleblock, titleblock_prims from .sheet.titleblock import apply_titleblock, titleblock_prims
from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
@@ -74,6 +79,8 @@ __all__ = [
"ensure_standard_linetypes", "ensure_standard_linetypes",
"apply_iso_a3_titleblock", "apply_iso_a3_titleblock",
"TitleBlockMetadata", "TitleBlockMetadata",
"apply_gost_form3_titleblock",
"GostTitleBlockMetadata",
"DrawingValidator", "DrawingValidator",
"ValidationResult", "ValidationResult",
"ValidationFinding", "ValidationFinding",
+26
View File
@@ -4,6 +4,32 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
#: Инлайн-группа шрифта ГОСТ — ровно та, что стоит в каждом многострочном тексте
#: образца Павла (``{\fISOCPEUR|b0|i0|c204|p34;\Q0;Вентиляционный канал}``):
#: ``c204`` — кириллический набор, ``p34`` — плотность 3/4, ``\Q0`` — прямой
#: наклон. Шрифт назван внутри самой строки, поэтому надпись выглядит одинаково
#: на любом компьютере, даже если стиль чертежа подменят или SHX не найдётся.
INLINE_FONT_CODE = r"{\fISOCPEUR|b0|i0|c204|p34;\Q0;"
INLINE_FONT_FACE = "ISOCPEUR"
#: Признаки того, что содержимое уже размечено управляющими кодами MTEXT.
_INLINE_MARKUP_CHARS = ("\\", "{", "}")
def wrap_inline_font(text: str, inline_font: bool = True) -> str:
"""Завернуть содержимое многострочного текста в инлайн-группу ISOCPEUR.
Уже размеченный текст (обратные слэши, группы в фигурных скобках) не
трогаем и пишем как есть: вложенная группа ломает разбор MTEXT, а образец
показывает, что готовые коды переносятся в файл без переписывания.
"""
if not inline_font:
return str(text)
raw = str(text)
if any(char in raw for char in _INLINE_MARKUP_CHARS):
return raw
return f"{INLINE_FONT_CODE}{raw}}}"
@dataclass(frozen=True) @dataclass(frozen=True)
class TableLayout: class TableLayout:
+517
View File
@@ -0,0 +1,517 @@
"""ГОСТ Р 21.1101-2013 форма 3 — основная надпись и рамка листа.
Геометрия взята с официального чертежа Приложения Ж (форма 3): штамп 185 x 55 мм
из строк по 5 мм, таблица изменений 65 мм (графы 14-19), правая часть 120 мм
(70 + 50), вложенные графы 6-8 «Стадия / Лист / Листов» шириной 15 / 15 / 20 мм.
Штамп ставится в правый нижний угол рамки; поля рамки 20/5/5/5 мм.
Смысл граф — по 5.2.7 и Приложению Ж ГОСТ Р 21.1101-2013:
1 — обозначение документа, 2 — наименование предприятия (объекта),
3 — наименование здания (сооружения), 4 — наименование изображений листа,
6 — стадия (П/Р), 7 — лист, 8 — листов, 9 — организация,
10-13 — характер работы, фамилия, подпись и дата,
14-19 — таблица изменений, 25 — масштаб, 26 — обозначение формата.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
# ── форматы листов (ГОСТ 2.301), мм ──────────────────────────────────────────
SHEETS: dict[str, tuple[float, float]] = {
"A4": (297.0, 210.0),
"A3": (420.0, 297.0),
"A2": (594.0, 420.0),
"A1": (841.0, 594.0),
"A0": (1189.0, 841.0),
}
#: Поля рамки по ГОСТ Р 21.1101-2013 (5.2.7, приложение И).
MARGIN_LEFT = 20.0
MARGIN_RIGHT = 5.0
MARGIN_TOP = 5.0
MARGIN_BOTTOM = 5.0
# ── геометрия штампа, мм ─────────────────────────────────────────────────────
STAMP_W = 185.0
STAMP_H = 55.0
ROW_H = 5.0 # 11 строк по 5 мм
LEFT_BLOCK_W = 65.0
RIGHT_BLOCK_W = 120.0
#: графы 14-19 таблицы изменений: (номер, подпись, ширина)
#: Ширины подобраны по метрике ISOCPEUR так, чтобы подпись графы вставала в
#: свою клетку при высоте 1.8 мм (ГОСТ 2.304): «Кол.уч.» и «№ док.» — 6 знаков,
#: то есть 9.1 мм, и в прежние 10 мм они влезали только после уменьшения до
#: 1.36/1.59 мм, что ниже нормы читаемости. Сумма остаётся 65 мм.
CHANGE_COLUMNS: tuple[tuple[int, str, float], ...] = (
(14, "Изм.", 10.0),
(15, "Кол.уч.", 11.0),
(16, "Лист", 8.0),
(17, "№ док.", 11.0),
(18, "Подп.", 10.0),
(19, "Дата", 15.0),
)
#: графы 6-8: (номер, подпись, ширина)
SHEET_COLUMNS: tuple[tuple[int, str, float], ...] = (
(6, "Стадия", 15.0),
(7, "Лист", 15.0),
(8, "Листов", 20.0),
)
#: Наименьшая высота надписи на листе (ГОСТ 2.304 / ISO 3098). Ниже неё
#: подпись не читается на печати — этим полом ограничено автоуменьшение
#: значений, а `drawing_export_pdf` отдельно сообщает о том, что всё-таки
#: оказалось мельче (поле warnings).
MIN_TEXT_H = 1.8
LABEL_H = 1.8 # подписи граф
VALUE_H = 3.0 # значения
TITLE_H = 5.0 # обозначение документа
GRAPH_NO_H = 1.8 # номера граф в скобках — не мельче нормы читаемости
PAD = 1.0
#: Отступ подписи графы от её линии. Меньше общего PAD: подписи «Кол.уч.» и
#: «№ док.» занимают по 9.1 мм, и при 1.0 мм с каждой стороны они не влезали в
#: 11-миллиметровую графу с первого раза.
TAG_PAD = 0.6
#: Зазор между номером графы в скобках и значением в той же клетке. Номер и
#: значение раньше стояли в одной точке, и «(25)Масштаб 1:50» печаталось
#: наложением.
TAG_GAP = 0.6
#: Графы 10-14: подписи по ГОСТ Р 21.1101-2013 (приложение Ж) — «Разраб.»,
#: «Пров.», «Т.контр.», «Н.контр.», «Утв.». Полные слова («Разработал.») при
#: 1.8 мм занимают 16.6 мм и выходят за 17-миллиметровую графу характера работы.
SIGN_ROWS: tuple[tuple[str, int], ...] = (
("Разраб.", 10),
("Пров.", 11),
("Т.контр.", 12),
("Н.контр.", 13),
("Утв.", 14),
)
#: Ширины колонок блока подписей (характер работы / фамилия / подпись / дата),
#: сумма 65 мм — ширина левой части штампа. Прежние 20/22/13/10 не вмещали
#: «01.10.2026» в 10 мм (дата ужималась до 1.2 мм), а 17 мм под фамилию
#: оставляют «Николашкин» читаемым (2.0 мм).
SIGN_LABEL_W = 17.0
SIGN_NAME_W = 18.0
SIGN_SIGN_W = 12.0
SIGN_DATE_W = 18.0
#: Ширина знака как доля высоты текста. Измерено по рендеру: «ЭО-01» высотой
#: 5 мм занимает по листу 21 мм, то есть ~0.84 высоты на знак у ISOCPEUR /
#: DejaVu Sans. Нужна, чтобы значения граф не выезжали за свои колонки —
#: `entity_create_text` выравнивает строку по левому краю от точки вставки.
CHAR_W_RATIO = 0.84
#: Полезная ширина графы 1, 2, 3, 4, 9 (70 мм минус отступы).
BLOCK_INNER_W = 68.0
LAYER_FRAME = "TITLEBLOCK"
LAYER_TEXT = "TEXT"
DEFAULT_TEXT_STYLE = "GOST_A"
DEFAULT_FONT = "isocpeur.ttf"
@dataclass
class GostTitleBlockMetadata:
"""Данные основной надписи по ГОСТ Р 21.1101-2013 (форма 3)."""
drawing_no: str = "" # графа 1 — обозначение документа
enterprise: str = "" # графа 2 — предприятие / объект строительства
building: str = "" # графа 3 — здание (сооружение)
title: str = "" # графа 4 — наименование изображений листа
stage: str = "Р" # графа 6 — стадия
sheet: str = "1" # графа 7 — лист
total_sheets: str = "1" # графа 8 — листов
organization: str = "" # графа 9 — организация
scale: str = "1:100" # графа 25 — масштаб
format: str = "A3" # графа 26 — формат листа
developed_by: str = "" # графа 11
developed_date: str = "" # графа 13
checked_by: str = "" # Проверил
checked_date: str = ""
norm_control: str = "" # Н. контр.
approved_by: str = "" # Утв.
change_rows: list[list[str]] = field(default_factory=list)
def _fmt(value: str) -> str:
return "" if value is None else str(value)
def _fit_height(value: str, height: float, max_width: float = BLOCK_INNER_W) -> float:
"""Уменьшить высоту строки, если она не влезает в графу по ширине.
Графы 2, 3, 4 и 9 имеют фиксированную ширину 70 мм, а наименования бывают
длинными («План расположения электрооборудования» — 37 знаков). Переносить
строку внутри пятимиллиметровой строки штампа некуда, поэтому длинное
значение рисуется мельче — так, чтобы остаться в своей графе, а не
наезжать на соседние.
Ниже ``MIN_TEXT_H`` (1.8 мм) не опускаемся: подпись мельче нормы читаемости
не читается на печати, и лучше чуть выйти за клетку, чем отдать лист с
нечитаемой основной надписью.
"""
text = _fmt(value)
if not text or not height:
return height
width = len(text) * height * CHAR_W_RATIO
if width <= max_width:
return height
return max(MIN_TEXT_H, round(max_width / (len(text) * CHAR_W_RATIO), 2))
def _tag_width(number: int, height: float = GRAPH_NO_H) -> float:
"""Ширина номера графы в скобках — '(14)', '(25)'."""
return len(f"({number})") * height * CHAR_W_RATIO
def _value_after_tag(base_x: float, number: int, height: float = GRAPH_NO_H) -> float:
"""X значения в клетке, где слева уже стоит номер графы.
Раньше номер и значение рисовались из одной точки, и «(25)Масштаб 1:50»
печаталось наложением друг на друга (претензия Павла «надписи накладываются
друг на друга», разобрано 01.10.2026).
"""
return base_x + _tag_width(number, height) + TAG_GAP
async def ensure_layers(backend, names) -> dict:
"""Создать слои, если их нет (не мешая уже существующим)."""
result: dict[str, bool] = {}
try:
existing = {str(entry.name) for entry in await backend.layer_list()}
except Exception:
return result
for name in names:
if name in existing:
result[name] = False
continue
try:
await backend.layer_create(name)
result[name] = True
except Exception:
result[name] = False
return result
async def set_layout_paper(backend, layout: str, sheet_w: float, sheet_h: float) -> dict:
"""Привести размер листа (страницы) к формату, под который нарисована рамка.
`drawing_export_pdf(page_size="layout")` берёт страницу PDF из
`Layout.dxf.paper_width/paper_height`. Документ, созданный `drawing_new`,
начинается с A3 независимо от формата, переданного в `sheet_format`, поэтому
рамка A4 уезжала в угол страницы A3. Бэкенд без `layout_set_paper` (COM) не
считается ошибкой: там страница задаётся настройкой печати AutoCAD.
"""
setter = getattr(backend, "layout_set_paper", None)
if setter is None:
return {"ok": False, "reason": "backend has no layout_set_paper"}
try:
return await setter(layout, sheet_w, sheet_h)
except Exception as exc: # размер страницы не должен валить построение рамки
return {"ok": False, "error": str(exc)}
async def ensure_text_style(backend, style: str, font: str, set_default: bool = True) -> dict:
"""Best-effort: настроить текстовый стиль с поддержкой кириллицы."""
ensure = getattr(backend, "textstyle_ensure", None)
if ensure is None:
return {"ok": False, "reason": "backend has no textstyle support"}
try:
return await ensure(style, font=font, set_default=set_default)
except Exception as exc: # стиль не должен валить построение штампа
return {"ok": False, "error": str(exc)}
async def apply_gost_form3_titleblock(
backend: "AutoCADBackend",
*,
metadata: GostTitleBlockMetadata,
sheet: str = "A3",
origin: tuple[float, float] = (0.0, 0.0),
layout: str | None = None,
text_style: str = DEFAULT_TEXT_STYLE,
text_font: str = DEFAULT_FONT,
) -> dict:
"""Нарисовать рамку листа и основную надпись формы 3 в её правом нижнем углу.
`layout` — имя листа (paper space); без него рисуется в текущем пространстве.
`sheet` — формат листа (A3, A4, ...), он же уходит в графу 26.
"""
sheet_key = str(sheet or "A3").strip().upper()
if sheet_key not in SHEETS:
raise RuntimeError(
f"apply_gost_form3_titleblock: unknown sheet {sheet!r} "
f"(known: {', '.join(sorted(SHEETS))})"
)
sheet_w, sheet_h = SHEETS[sheet_key]
ox, oy = float(origin[0]), float(origin[1])
previous_space = "Model"
if layout is not None:
listing = await backend.layout_list()
names = {str(entry) for entry in (listing.get("layouts") or [])}
if layout not in names:
raise RuntimeError(
f"apply_gost_form3_titleblock: layout {layout!r} does not exist "
f"(have: {', '.join(sorted(names))}). Create it with layout_create first."
)
previous_space = getattr(backend, "_current_space", "Model")
switched = await backend.layout_set_current(layout)
if not switched.get("ok", True):
raise RuntimeError(f"apply_gost_form3_titleblock: {switched.get('error')}")
try:
style_status = await ensure_text_style(backend, text_style, text_font, True)
layers_status = await ensure_layers(backend, (LAYER_FRAME, LAYER_TEXT))
paper_status = (
await set_layout_paper(backend, layout, sheet_w, sheet_h)
if layout is not None
else {"ok": False, "reason": "no layout given"}
)
drawn = await _draw(
backend,
metadata=metadata,
ox=ox,
oy=oy,
sheet_key=sheet_key,
sheet_w=sheet_w,
sheet_h=sheet_h,
layout=layout,
style_status=style_status,
layers_status=layers_status,
)
if isinstance(drawn, dict):
drawn.setdefault("paper_status", paper_status)
return drawn
finally:
if layout is not None:
await backend.layout_set_current(previous_space)
async def _draw(
backend,
*,
metadata: GostTitleBlockMetadata,
ox: float,
oy: float,
sheet_key: str,
sheet_w: float,
sheet_h: float,
layout: str | None,
style_status: dict,
layers_status: dict,
) -> dict:
async def line(x1: float, y1: float, x2: float, y2: float) -> str:
ent = await backend.entity_create_line(x1, y1, x2, y2, layer=LAYER_FRAME)
return ent.handle
async def text(
value: str,
x: float,
y: float,
height: float = LABEL_H,
*,
rotation: float = 0.0,
) -> str | None:
value = _fmt(value)
if not value:
return None
ent = await backend.entity_create_text(
text=value, x=x, y=y, height=height, rotation=rotation, layer=LAYER_TEXT
)
return ent.handle
handles: list[str] = []
# ── рамка листа: поля 20/5/5/5 ───────────────────────────────────────────
fx0 = ox + MARGIN_LEFT
fy0 = oy + MARGIN_BOTTOM
fx1 = ox + sheet_w - MARGIN_RIGHT
fy1 = oy + sheet_h - MARGIN_TOP
frame = await backend.entity_create_polyline(
points=[[fx0, fy0], [fx1, fy0], [fx1, fy1], [fx0, fy1]],
closed=True,
layer=LAYER_FRAME,
)
handles.append(frame.handle)
# ── штамп: правый нижний угол рамки ──────────────────────────────────────
sx0 = fx1 - STAMP_W
sy0 = fy0
sx1 = fx1
sy1 = fy0 + STAMP_H
stamp_frame = await backend.entity_create_polyline(
points=[[sx0, sy0], [sx1, sy0], [sx1, sy1], [sx0, sy1]],
closed=True,
layer=LAYER_FRAME,
)
handles.append(stamp_frame.handle)
left_x = sx0 + LEFT_BLOCK_W # 65 мм
split_70 = sx0 + LEFT_BLOCK_W + 70.0 # 135 мм
split_sheet = split_70 + SHEET_COLUMNS[0][2] # 150 мм
split_sheet2 = split_sheet + SHEET_COLUMNS[1][2] # 165 мм
# горизонтальные линии: строки по 5 мм
row_lines: list[float] = []
for i in range(1, int(STAMP_H / ROW_H)):
y = round(sy0 + i * ROW_H, 4)
row_lines.append(y)
handles.append(await line(sx0, y, sx1, y))
# правая часть штампа: нижняя полоса граф 25/26 отделена, а строки
# граф 1-4 идут от sy0+5 вверх
handles.append(await line(left_x, sy0, left_x, sy1))
handles.append(await line(split_70, sy0, split_70, sy1))
handles.append(await line(split_70, sy1 - 25.0, sx1, sy1 - 25.0)) # верх блока 6-8
handles.append(await line(split_sheet, sy1 - 25.0, split_sheet, sy1 - 10.0))
handles.append(await line(split_sheet2, sy1 - 25.0, split_sheet2, sy1 - 10.0))
handles.append(await line(split_sheet, sy1 - 30.0, sx1, sy1 - 30.0)) # подписи «Стадия/Лист/Листов»
# ── таблица изменений (графы 14-19) ─────────────────────────────────────
x = sx0
for number, label, width in CHANGE_COLUMNS:
await text(f"({number})", x + TAG_PAD, sy1 - ROW_H + 0.6, GRAPH_NO_H)
# «Кол.уч.» и «№ док.» при 1.8 мм занимают по 9.1 мм: подписи
# подгоняются по полезной ширине графы, а сами графы расширены до 11 мм,
# чтобы подпись не уменьшалась ниже ГОСТовских 1.8 мм.
await text(label, x + TAG_PAD, sy1 - 2 * ROW_H + 0.6,
_fit_height(label, LABEL_H, width - 2 * TAG_PAD))
x += width
if x < left_x - 0.01:
handles.append(await line(x, sy1 - 2 * ROW_H, x, sy0))
# пустые строки таблицы изменений: 4 строки (sy1-20 .. sy1-45)
for row_index, row in enumerate(list(metadata.change_rows)[:4]):
y = sy1 - (2 + row_index + 1) * ROW_H + 0.6
x = sx0
for cell, (_, _, width) in zip(list(row) + [""] * 6, CHANGE_COLUMNS):
if _fmt(cell):
await text(_fit_height(_fmt(cell), LABEL_H, width - 2 * TAG_PAD),
x + TAG_PAD, y)
x += width
# ── подписи (графы 10-14) ───────────────────────────────────────────────
sign_values = (
(metadata.developed_by, metadata.developed_date),
(metadata.checked_by, metadata.checked_date),
("", ""),
(metadata.norm_control, ""),
(metadata.approved_by, ""),
)
sign_rows = [
(label, number, name, date)
for (label, number), (name, date) in zip(SIGN_ROWS, sign_values)
]
# Пропорции блока подписей: характер работы 17 мм, фамилия 18 мм, подпись
# 12 мм, дата 18 мм (сумма 65). Прежние 20/22/13/10 не вмещали ни «01.10.2026»
# (дата ужималась до 1.2 мм — вдвое мельче нормы), ни полные подписи.
col_name_x = sx0 + SIGN_LABEL_W # Ф.И.О.
col_sign_x = col_name_x + SIGN_NAME_W # подпись
col_date_x = col_sign_x + SIGN_SIGN_W # дата
# Строки 7-11 (индекс 0 = верхняя): таблица изменений занимает строки 1-6
# (две строки подписей граф 14-19 и четыре пустых), подписи 10-14 — строки
# 7-11. Смещение считалось от строки 8, поэтому пятая подпись («Утв.»)
# рисовалась на 5 мм НИЖЕ низа штампа — за пределами рамки листа.
for index, (label, number, name, date) in enumerate(sign_rows):
top = sy1 - (6 + index) * ROW_H
y = top - ROW_H + 0.7
# Номер графы — в верхнем левом углу СВОЕЙ клетки. Он стоял на 1.4 мм
# выше верхней линии, то есть в соседней строке: «(11)» печаталось в
# клетке «Разраб.», «(14)» — в строке «Масштаб».
await text(f"({number})", sx0 + TAG_PAD, top - GRAPH_NO_H - 0.3, GRAPH_NO_H)
await text(label, sx0 + 2.6, y, _fit_height(label, LABEL_H, SIGN_LABEL_W - 3.6))
await text(name, col_name_x + TAG_PAD, y,
_fit_height(name, VALUE_H, SIGN_NAME_W - 2 * TAG_PAD))
# Дата — графа 13: «01.10.2026» в 1.8 мм занимает 15.1 мм, поэтому
# колонка расширена до 18 мм, а высота подгоняется по её полезной ширине.
await text(date, col_date_x + TAG_PAD, y,
_fit_height(date, LABEL_H, SIGN_DATE_W - 2 * TAG_PAD))
for gx in (col_name_x, col_sign_x, col_date_x):
handles.append(await line(gx, sy0, gx, sy1 - 6 * ROW_H))
# ── графа 1: обозначение документа ──────────────────────────────────────
# Значение выравнивается по правому краю графы 1: `entity_create_text`
# ставит текст по левому краю от точки вставки, поэтому обозначение,
# записанное «от правой границы», уезжало в графы 6-8. Ширина считается по
# фактической метрике знака (CHAR_W_RATIO), а не «на глаз».
await text("(1)", sx0 + LEFT_BLOCK_W + PAD, sy1 - 2 * ROW_H, GRAPH_NO_H)
await text(
metadata.drawing_no,
split_70 - 1.5 - len(_fmt(metadata.drawing_no)) * TITLE_H * CHAR_W_RATIO,
sy1 - ROW_H - 3.4,
TITLE_H,
)
# Значения граф 2-4 рисуются ПОСЛЕ номера графы, а не из той же точки:
# раньше «(2)» и «ИП Николашкин П.В.» печатались полностью наложенными
# друг на друга (проверено разбором текстового слоя PDF 01.10.2026).
tag_x = sx0 + LEFT_BLOCK_W + PAD
await text("(2)", tag_x, sy1 - 4 * ROW_H + 0.6, GRAPH_NO_H)
await text(metadata.enterprise, _value_after_tag(tag_x, 2), sy1 - 4 * ROW_H + 1.0,
_fit_height(metadata.enterprise, VALUE_H,
BLOCK_INNER_W - _tag_width(2) - TAG_GAP - PAD))
await text("(3)", tag_x, sy1 - 6 * ROW_H + 0.6, GRAPH_NO_H)
await text(metadata.building, _value_after_tag(tag_x, 3), sy1 - 6 * ROW_H + 1.0,
_fit_height(metadata.building, VALUE_H,
BLOCK_INNER_W - _tag_width(3) - TAG_GAP - PAD))
# графа 4 — наименование изображений листа
await text("(4)", tag_x, sy0 + 2 * ROW_H + 0.6, GRAPH_NO_H)
await text(metadata.title, _value_after_tag(tag_x, 4), sy0 + 2 * ROW_H,
_fit_height(metadata.title, VALUE_H,
BLOCK_INNER_W - _tag_width(4) - TAG_GAP - PAD))
# ── графа 9 — организация ───────────────────────────────────────────────
await text("(9)", split_70 + PAD, sy0 + 2 * ROW_H + 0.6, GRAPH_NO_H)
# Ниже y = 30 правая часть штампа (365..415) не делится — это графа 9 в
# 50 мм; длинное название организации подгоняем по 47 мм полезной ширины.
await text(metadata.organization, split_70 + PAD, sy0 + ROW_H + 0.4,
_fit_height(metadata.organization, VALUE_H, 47.0))
# ── графы 6-8: Стадия / Лист / Листов ───────────────────────────────────
header_y = sy1 - 25.0 - ROW_H + 1.4
x = split_70
for number, label, width in SHEET_COLUMNS:
await text(label, x + PAD, header_y, LABEL_H)
await text(f"({number})", x + PAD, sy1 - 25.0 - ROW_H - 1.6, GRAPH_NO_H)
x += width
await text(metadata.stage, split_70 + PAD, sy1 - 30.0 - ROW_H - 0.6, VALUE_H)
await text(metadata.sheet, split_sheet + PAD, sy1 - 30.0 - ROW_H - 0.6, VALUE_H)
await text(metadata.total_sheets, split_sheet2 + PAD, sy1 - 30.0 - ROW_H - 0.6, VALUE_H)
# ── графы 25 (масштаб) и 26 (формат) ────────────────────────────────────
await text("(25)", tag_x, sy0 + 1.4, GRAPH_NO_H)
await text(f"Масштаб {metadata.scale}", _value_after_tag(tag_x, 25), sy0 + 0.7, LABEL_H)
await text("(26)", split_70 + PAD, sy0 + 1.4, GRAPH_NO_H)
await text(f"Формат {sheet_key}", _value_after_tag(split_70 + PAD, 26), sy0 + 0.7, LABEL_H)
return {
"ok": True,
"standard": "ГОСТ Р 21.1101-2013, форма 3",
"sheet": sheet_key,
"layout": layout or "Model",
"frame": frame.handle,
"stamp_frame": stamp_frame.handle,
"lines": handles,
"text_style": style_status,
"layers": layers_status,
"frame_margins": {
"left": MARGIN_LEFT,
"right": MARGIN_RIGHT,
"top": MARGIN_TOP,
"bottom": MARGIN_BOTTOM,
},
"stamp_bbox": {
"min": [sx0, sy0],
"max": [sx1, sy1],
"width": STAMP_W,
"height": STAMP_H,
},
"frame_bbox": {"min": [fx0, fy0], "max": [fx1, fy1]},
"metadata": asdict(metadata),
}
+836 -54
View File
File diff suppressed because it is too large Load Diff