В прошлой статье я разбирал флот из тридцати трёх ИИ‑агентов и в разделе про энергетику упомянул вскользь: чертёж там рисуется одной функцией сразу в двух форматах. Большинство читателей эту фразу пропустили, а зря — за ней стоит развилка, на которой я потерял недели.
Задача звучит просто. Есть данные — параметры подстанции, состав оборудования, нагрузки. Нужен чертёж: не картинка, а лист формата А3, который инженер откроет в AutoCAD и поправит.
Очевидный путь — взять AutoCAD и рисовать в нём программно. Я так и сделал: подключился к нему из Python по COM и начал водить его снаружи. Работает. Но когда дошло до генерации по данным, выяснилось, что CAD в этой задаче чаще мешает, чем помогает.
Разберу оба пути на живом коде: где COM незаменим, где он тихо врёт, и почему в итоге чертежи у меня генерирует код, не открывающий никакого CAD вообще.
Вот что на выходе — лист, собранный кодом из строки текста с параметрами:

Тот же лист выгружается в DXF и открывается в AutoCAD для правки. Подпись «требуется разработка РД» стоит не для красоты: это стадия «П», и генератор говорит об этом сам.
TL;DR
COM управляет живым приложением снаружи. Ничего не устанавливается внутрь CAD, ничего не компилируется,
SECURELOADи доверенные пути не при чём — код просто дёргает методы у запущенной программы.COM‑слой тихо врёт. Вызовы возвращают успех, системная переменная читается обратно той, что записал, а чертёж неверный. Единственная защита — проверять геометрию измерением, а не чтением настройки.
Для генерации по данным CAD не нужен. DXF формата R12 — текстовый, его можно печатать напрямую, без библиотек.
Один холст, два формата. Экспортёр DXF реализует тот же набор методов, что и SVG‑холст, поэтому одна функция компоновки рисует в оба, и геометрия физически не может разойтись.
Проверка результата: не «файл существует», а сигнатура, заголовок и совпадение размеров с ожидаемыми.
Путь первый: водить живой CAD снаружи
COM — механизм Windows, поэтому весь этот раздел про Windows и только про него. На macOS и Linux он неприменим целиком, а не частично.
pip install pywin32
Подключение занимает три строки:
import pythoncom, win32com.client pythoncom.CoInitialize() app = win32com.client.GetActiveObject("AutoCAD.Application")
GetActiveObject цепляется к уже запущенному приложению. Есть парный Dispatch, который приложение поднимет, — медленно и отбирая фокус. Разница между ними важнее, чем кажется, и я на ней обжёгся.
Первая версия перебирала CAD‑ы наивно: для каждого сначала GetActiveObject, потом сразу Dispatch. Логика выглядела разумной — попробуй подключиться, не вышло, запусти. На практике при незапущенном AutoCAD код запускал его прежде, чем вообще пробовал подключиться к уже работающему GstarCAD во второй строке списка. Правильный порядок — два прохода: сначала обойти все ProgID на предмет работающего приложения, и только потом соглашаться что‑то поднимать.
for grab in (win32com.client.GetActiveObject, win32com.client.Dispatch): for progid in candidates: try: return grab(progid) except pythoncom.com_error as exc: failures.append(f"{progid} / {grab.__name__}: {exc.strerror or exc}")
Совместимых ProgID пять: AutoCAD.Application (плюс версионные, .26 — это 2027), GstarCAD.Application, ZWCAD.Application, BricscadApp.AcadApplication, nanoCAD.Application. Объектная модель у них одна, код не меняется.
Отдельно стоит вынести функцию, которая не поднимает ничего:
def attach_only(progids=None): """Подключиться к уже работающему CAD и НЕ поднимать ничего.""" pythoncom.CoInitialize() for progid in progids or PROGIDS: try: return win32com.client.GetActiveObject(progid) except pythoncom.com_error as exc: failures.append(f"{progid}: {exc.strerror or exc}") raise RuntimeError("нет ЗАПУЩЕННОГО CAD:\n " + "\n ".join(failures))
Именно отдельной функцией, а не флагом. Пакетный прогон, чужая машина, задача по расписанию — там запуск приложения недопустим, а отличить один режим от другого булевым параметром слишком легко забыть.
Текст ошибки читать обязательно
Три совершенно разные ситуации выглядят одинаково — «не подключилось»:
Текст ошибки | Что произошло |
|---|---|
| ProgID не зарегистрирован — программа не установлена |
| Установлена, но не запущена |
| Windows попыталась запустить, и процесс умер |
Разница между первой и второй строкой — это разница между «поставь программу» и «открой программу». Пока не читаешь текст, обе выглядят как «почини скрипт».
Точки — не списки
Любой аргумент‑точка передаётся как VARIANT‑массив двойных, а не как список Python:
from win32com.client import VARIANT def pt(x, y, z=0.0): return VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_R8, [float(x), float(y), float(z)])
Полилиния принимает один плоский массив [x1, y1, x2, y2, ...]. Массивы объектов — например, границы штриховки — требуют VT_ARRAY | VT_DISPATCH.
Где COM тихо врёт
Это главное, что стоит знать перед тем, как строить на нём что‑то серьёзное. Вызовы возвращают успех. Свойства читаются обратно теми значениями, которые вы записали. Чертёж при этом неправильный.
Размеры без текста и стрелок. Ставите SetVariable("DIMSCALE", 50), читаете обратно — пятьдесят. Рисуете размер — на чертеже линия со стрелками и текстом высотой 2,5 единицы, то есть невидимым на листе, размеченном в миллиметрах. Системная переменная не доходит до объектов, созданных через ActiveX. Работает только присвоение самому объекту:
def linear_dim(msp, p1, p2, text_pos, scale, vertical=False, layer=None): """A dimension that is actually visible.""" dim = msp.AddDimRotated( pt(*p1), pt(*p2), pt(*text_pos), math.pi / 2 if vertical else 0.0 ) dim.ScaleFactor = float(scale) return dim
Булева операция сбрасывает цвет. После вычитания результат возвращается к ByLayer — ровно тогда, когда цвет и нужен, потому что по нему вы собирались отличать детали. Идентифицировать части после булевых операций приходится по объёму или слою.
Удаление объектов в листе роняет приложение. Проходите циклом по всем сущностям листа, удаляете — и вместе с ними уходит последний видовой экран. Дальше Сервер RPC недоступен, AutoCAD закрыт. Удалять можно только то, у чего ObjectName != "AcDbViewport".
PlotToFile возвращает True и не создаёт файла. Либо создаёт двухкилобайтную заглушку. Причина — BACKGROUNDPLOT по умолчанию равен двум, печать уходит в фон и падает молча.
Незавершённая команда вешает весь интерфейс. SendCommand асинхронный и ничего не возвращает. Если команда осталась висеть на командной строке, следующий же вызов любого свойства падает с <unknown>.Name, <unknown>.Count. Выглядит это как испорченный кэш gen_py — и чистка кэша не помогает, потому что дело не в нём. Лечится отправкой ESC в окно и переходом на системные переменные вместо команд, с проверкой CMDACTIVE перед каждым шагом.
AddBox принимает центр. Не угол, как почти везде в графических API. Мелочь, но она стоит одного недоумённого вечера:
def box(msp, x0, y0, z0, dx, dy, dz, color=None): """AddBox takes the CENTRE, which is rarely what you have.""" return msp.AddBox(pt(x0 + dx / 2, y0 + dy / 2, z0 + dz / 2), float(dx), float(dy), float(dz))
Отказ в вызове — не ошибка. Вызов был отклонен (RPC_E_CALL_REJECTED) означает, что приложение занято перерисовкой. Это состояние, а не сбой: повторить через секунду. А вот всё остальное надо пробрасывать сразу, иначе цикл повторов проглотит настоящую ошибку:
# HRESULT приходит из pywin32 знаковым: 0x80010001 не влезает в int32, # и старший бит превращает его в отрицательное число. def _hr(code): return code - 0x1_0000_0000 RPC_E_CALL_REJECTED = _hr(0x80010001) # приложение занято перерисовкой RPC_E_SERVERCALL_RETRYLATER = _hr(0x8001010A) # то же, другая формулировка def com_retry(fn, tries=15, delay=1.0): for _ in range(tries): try: return fn() except pythoncom.com_error as exc: if exc.hresult not in (RPC_E_CALL_REJECTED, RPC_E_SERVERCALL_RETRYLATER): raise time.sleep(delay)
Отсюда следует правило
Проверять надо измерением, а не чтением настройки, которую сам же и записал:
mn, mx = solid.GetBoundingBox() print(mx[0] - mn[0], mx[1] - mn[1], mx[2] - mn[2]) print(solid.Volume / 1e6, "литров")
И сверять объём с расчётом на бумаге. Корпус, собранный как «внешняя коробка минус полость минус вырез», обязан совпасть с суммой эквивалентных пластин. Не совпал — геометрия неверна, как бы правдоподобно она ни выглядела на экране.
Путь второй: чертёж без всякого CAD
Всё вышеописанное отлично работает, когда надо править существующий чертёж или строить трёхмерную модель интерактивно. Но моя задача была другой: сгенерировать лист по данным, на сервере, без человека за монитором.
И тут COM начинает мешать. Ему нужно установленное приложение — с лицензией. Нужен графический сеанс: это не служба, это окно. Нужна Windows. Он занят, когда приложение перерисовывает экран. Он падает, если кто‑то случайно закрыл окно.
Проверить это можно за десять секунд. Вот что отвечает моя машина прямо сейчас:
нет: AutoCAD.Application | Недопустимая строка с указанием класса нет: GstarCAD.Application | Недопустимая строка с указанием класса нет: ZWCAD.Application | Недопустимая строка с указанием класса нет: BricscadApp.AcadApplication| Недопустимая строка с указанием класса нет: nanoCAD.Application | Недопустимая строка с указанием класса
Ни одного CAD не установлено. Значит, ни один пример из первой половины статьи я сейчас воспроизвести не могу, а чертежи при этом генерируются — потому что для второго пути ничего из этого не требуется.
DXF — это текст
Ключ ко всему: формат DXF версии R12 достаточно прост, чтобы печатать его напрямую. Никакой библиотеки. Линия выглядит так:
def _line(self, x1, y1, x2, y2, layer=None, dash=None): layer = layer or self.current_layer ltype = "DASHED" if dash else "CONTINUOUS" self.ents.append( f"0\nLINE\n8\n{layer}\n6\n{ltype}\n" f"10\n{x1:.3f}\n20\n{self._y(y1):.3f}\n30\n0.000\n" f"11\n{x2:.3f}\n21\n{self._y(y2):.3f}\n31\n0.000\n")
Это group codes: 0 — тип сущности, 8 — слой, 6 — тип линии, 10/20/30 — первая точка, 11/21/31 — вторая. Файл, собранный из таких строк, открывается в AutoCAD и правится.
Две вещи, которые придётся учесть. Первая: ось Y инвертируется — в SVG она растёт вниз, в DXF вверх. Отсюда self._y(y) в каждой координате. Вторая: кодировка cp1251 (ANSI_1251), иначе кириллица в AutoCAD RU превращается в мусор. А раз кодировка однобайтовая, всё, что в неё не влезает, надо заменить заранее:
def _san(s): """Замена символов, отсутствующих в cp1251/шрифтах CAD, на ASCII-аналоги.""" return (cleaned.replace("≤", "<=").replace("≥", ">=").replace("×", "x") .replace("Δ", "D").replace("—", "-").replace("–", "-") .replace("·", "-").replace("²", "2").replace("°", "гр.") .replace("₽", "руб").replace("…", "..."))
Список выглядит случайным, но каждый символ в нём попал туда после того, как испортил реальный лист.
Один холст, два формата
Вот приём, ради которого всё это писалось. Экспортёр DXF не имеет собственного кода отрисовки — он мимикрирует под холст:
class DxfCanvas: """Мимикрирует Sheet: те же методы, но копит DXF-сущности. Y инвертируется."""
У него те же методы, что у SVG‑холста: line, rect, circle, dot, path, poly, text. Поэтому функция компоновки, которая рисует схему, вообще не знает, во что она рисует:
from render_svg import draw_sheet, SHEET_H, SHEET_W c = DxfCanvas() draw_sheet(c, sch) # диспетчер: КТП — однолинейная схема, ВЛ-10 — схема линии
Та же draw_sheet вызывается и с SVG‑холстом. Геометрия в вебе и в AutoCAD физически не может разойтись — она получена одним и тем же кодом. Альтернатива, два независимых генератора, расходится на третьей правке: кто‑то поправит отступ в SVG и забудет про DXF.

Функция компоновки не знает, во что она рисует. Холсты отвечают только за то, как записать примитив: SVG — тегом, DXF — group codes с инверсией Y.
В цифрах один лист — это 141 текстовый объект, 280 линий и 22 окружности, 51 КБ файла для двухтрансформаторной подстанции. Лист А3, 420 на 297 миллиметров, задан константами SHEET_W, SHEET_H в одном месте.
Считать сущности в DXF надо по паре строк «0, тип», а не поиском слова в файле: подстрока TEXT встречается в нём 267 раз, хотя текстовых объектов 141. На этом легко ошибиться вдвое — я и ошибся, пока не пересчитал.
Растр — браузером
Третий формат — картинка для чата. Её рисует headless‑браузер: он уже умеет и кириллицу, и системные шрифты, и SVG, который у нас и так есть. Никаких cairosvg и возни со шрифтовыми зависимостями.
Интереснее то, как проверяется результат. Не «файл существует»:
size = png_path.stat().st_size head = png_path.read_bytes()[:24] valid_signature = head[:8] == b"\x89PNG\r\n\x1a\n" valid_ihdr = len(head) == 24 and head[12:16] == b"IHDR" actual_wh = struct.unpack(">II", head[16:24]) if valid_ihdr else (0, 0) if size < 1024 or not valid_signature or not valid_ihdr or actual_wh != (w_px, h_px): png_path.unlink(missing_ok=True) raise RuntimeError(f"Некорректный PNG: size={size}, dimensions={actual_wh}, " f"expected={(w_px, h_px)}")
Код возврата браузера, сигнатура файла, заголовок IHDR и совпадение размеров с заказанными. Комментарий рядом объясняет, откуда это взялось: обрезанные и чёрные артефакты раньше проходили простую проверку существования файла.
И отдельная строчка выше по коду — старый файл удаляется до рендера:
png_path.unlink(missing_ok=True)
Иначе прошлый успешный запуск маскирует сегодняшний сбой, и вы неделю смотрите на правильную картинку, не замечая, что генератор давно сломан.
Что выбрать
Через CAD по COM | Генерацией файла | |
|---|---|---|
Что нужно на машине | установленный и запущенный CAD, лицензия, графический сеанс, Windows | ничего |
Где работает | рабочая станция инженера | контейнер на сервере, CI, что угодно |
Правка существующего DWG | да, это его сильная сторона | нет |
3D‑тела, булевы операции | да | нет |
Генерация по данным пачками | плохо: приложение занято, падает, отбирает фокус | это и есть его задача |
Отладка | вызов вернул успех, а чертёж неверный | файл либо валиден, либо нет |
Правило, к которому я пришёл: если чертёж рождается из данных — генерируйте файл. Если чертёж уже есть и его надо изменить — берите COM. Смешивать эти сценарии в одном коде не стоит: у них разные требования к среде, и первый же перенос на сервер разведёт их принудительно.
Что бы сделал иначе
Начал бы со второго пути. Я потратил время на COM, потому что «чертёж = AutoCAD» казалось очевидным. Для генерации это не так. Стоило сначала попробовать напечатать DXF текстом — это два вечера, и они бы сэкономили две недели.
Сразу писал бы санитайзер символов. Все замены в _san появились по одной, каждая после испорченного листа. Тире, знак умножения, градусы, квадратные метры — этот набор предсказуем для любого русского чертежа, и его можно было заложить сразу.
Не доверял бы exists() с самого начала. Проверка PNG по сигнатуре появилась после того, как в чат ушёл чёрный лист. Проверка «файл на месте» не проверяет ничего, кроме того, что диск не отвалился.
Чек‑лист для тех, кто пойдёт этим путём
[ ] Прочитать текст COM‑ошибки, а не гадать: «не установлено» и «не запущено» лечатся по‑разному
[ ]
GetActiveObjectдля всех ProgID, и только потомDispatch— иначе запустите не то приложение[ ] Отдельная функция «только подключиться», без запуска — для пакетных прогонов
[ ] Размерам ставить
ScaleFactorна объекте;DIMSCALEчерез ActiveX не доходит[ ] После булевых операций не искать детали по цвету
[ ] В листе не удалять
AcDbViewport[ ] Проверять геометрию через
GetBoundingBoxиVolume, сверяя с расчётом на бумаге[ ] Для генерации по данным — печатать DXF R12 текстом, в cp1251, с инверсией Y
[ ] Один холст с общим API на все форматы, а не отдельный генератор под каждый
[ ] Растр проверять по сигнатуре и размерам из заголовка, а старый файл удалять до рендера
Про свои эксперименты с агентами и инженерной автоматизацией пишу в канале «Готовим ИИшницу».
Если захотите повторить, а затык окажется не в идее, а в установке — поставить Claude Code, разобраться с регистрацией и настроить конфигурацию так, чтобы агент делал что‑то полезное, — у меня для этого есть Ника, агент‑помощник: t.me/vibecodeguidebot. Там же моя конфигурация целиком, установщик и короткий видео‑гайд. Бесплатно, регистрация не нужна.

