Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cee071f9f | ||
|
|
1cc9043d70 | ||
|
|
2e2a2b96a9 |
-121
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 инструмент, не тронут.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user