Files
autocad-mcp-pro/engineering/gost_titleblock.py
T
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

518 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ГОСТ Р 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),
}