"""AutoCAD COM backend – live AutoCAD control via pywin32. All COM calls are routed through a single-threaded executor to satisfy AutoCAD's STA (Single-Threaded Apartment) COM requirement. """ from __future__ import annotations import asyncio import io import logging import math import os import sys import time import uuid from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any import config from engineering.measure import is_self_intersecting, polygon_area_perimeter from security import sanitize_macro_argument, sanitize_symbol_name, validate_path from . import ocs from .base import ( AutoCADBackend, BlockInfo, CapabilityMap, DrawingInfo, EntityInfo, FeatureCapability, LayerInfo, UnsupportedCapabilityError, deg2rad, normalize_lineweight, rad2deg, shoelace_area, ) from .contracts.settings import SUMMARY_FIELDS, validate_drawing_properties log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Optional COM imports # --------------------------------------------------------------------------- _WIN32_AVAILABLE = sys.platform == "win32" if _WIN32_AVAILABLE: try: import pythoncom import pywintypes import win32com.client import win32con # noqa: F401 import win32gui import win32ui _COM_IMPORTS_OK = True except ImportError: _COM_IMPORTS_OK = False else: _COM_IMPORTS_OK = False #: What `_run` may catch as a COM failure. A *tuple*, because where pywin32 is #: absent the right value is the empty one: `except ()` catches nothing, which #: is exactly correct when no `com_error` type exists to be raised. #: #: `except pywintypes.com_error` used to be written directly, and Python #: evaluates that name on the way out of every failed call — not only COM ones. #: On any host without pywin32 (all of Linux, and Windows installed without the #: `[com]` extra) it raised `NameError: name 'pywintypes' is not defined` and #: buried the real exception. A placeholder Exception subclass would be worse #: than the bug: it would silently swallow unrelated errors. _COM_ERROR: tuple[type[BaseException], ...] = (pywintypes.com_error,) if _COM_IMPORTS_OK else () try: from PIL import Image as PILImage _PIL_OK = True except ImportError: _PIL_OK = False # --------------------------------------------------------------------------- # COM thread-state (accessed ONLY from the COM executor thread) # --------------------------------------------------------------------------- _COM_STATE: dict[str, Any] = {} # keys: "app" def _com_init(): """COM thread initializer – called once by the ThreadPoolExecutor.""" if _COM_IMPORTS_OK: pythoncom.CoInitialize() def _com_teardown(): """COM thread finalizer — drop the cached app and release the STA apartment. Submitted to the executor (R5) so the CoInitialize done in _com_init is paired with a CoUninitialize when the worker is torn down or a stuck SendCommand eventually unblocks, instead of leaking the apartment forever. """ _COM_STATE.pop("app", None) if _COM_IMPORTS_OK: try: pythoncom.CoUninitialize() except Exception: pass def _apoint(x: float, y: float, z: float = 0.0): """Create a VARIANT double-array point for AutoCAD COM.""" return win32com.client.VARIANT( pythoncom.VT_ARRAY | pythoncom.VT_R8, [float(x), float(y), float(z)], ) def _av(values: list[float]): """Create a VARIANT double-array from a flat list.""" return win32com.client.VARIANT( pythoncom.VT_ARRAY | pythoncom.VT_R8, [float(v) for v in values], ) def _ai(values: list[int]): """Create a VARIANT short-int array.""" return win32com.client.VARIANT( pythoncom.VT_ARRAY | pythoncom.VT_I2, list(values), ) def _solid_3d_capability() -> FeatureCapability: """3D solids are opt-in (ENABLE_3D=true) even on the live COM backend.""" if config.settings.enable_3d: return FeatureCapability(True, "native") return FeatureCapability(False, reason="opt_in_disabled:set ENABLE_3D=true") def _acad_app(): """Return (or lazily create) the CAD Application COM object. Must only be called from the COM executor thread. The ProgID comes from ``CAD_PROGID`` (default ``AutoCAD.Application``) and is honoured on *both* paths deliberately. ``Dispatch`` is not a passive probe — it COM-launches the application and makes it visible — so honouring the setting on ``GetActiveObject`` and then falling back to AutoCAD would start the very application the operator said they were not using. """ if "app" not in _COM_STATE: progid = config.settings.cad_progid try: _COM_STATE["app"] = win32com.client.GetActiveObject(progid) except Exception as exc: log.debug("GetActiveObject(%r) failed, trying Dispatch: %s", progid, exc) try: _COM_STATE["app"] = win32com.client.Dispatch(progid) _COM_STATE["app"].Visible = True except Exception as exc: raise RuntimeError( f"Cannot connect to CAD application {progid!r}: {exc}. Make sure it is " f"installed and running. (Set CAD_PROGID to change it; default " f"{config.DEFAULT_CAD_PROGID!r}.)" ) from exc return _COM_STATE["app"] def _acad_doc(): """Return active AutoCAD document.""" app = _acad_app() if app.Documents.Count == 0: raise RuntimeError("No drawing is open in AutoCAD. Open or create a .dwg file first.") return app.ActiveDocument def _msp(): """The space new geometry goes into — the active layout's block. This returned ``ModelSpace`` unconditionally, so ``layout_set_current`` reported success and everything drawn afterwards still landed in model space. ``ActiveLayout.Block`` is the same object as ``ModelSpace`` while the Model tab is active, so the model-space path is unchanged; on a paper layout it is the sheet, which is where a title block belongs. """ doc = _acad_doc() try: return doc.ActiveLayout.Block except Exception as exc: # never strand a write over a layout probe log.debug("ActiveLayout.Block unavailable (%s); using ModelSpace", exc) return doc.ModelSpace def _int_member(obj, name: str) -> int | None: """``int(obj.)`` or ``None`` when the member is absent or not a number.""" try: return int(getattr(obj, name)) except Exception: return None def _owner_layout_block(doc, ent, handle): """The block table record that owns ``ent``, refused unless it is a layout. ``Explode()`` places the members in the owner space, so anything this method adds alongside them must go there too — never ``ActiveLayout``, which is whatever tab the user happens to have open. An owner that is a block definition means ``ent`` is a nested reference; exploding it in place would redefine the block under every other reference, so it is refused before any call is dispatched. An owner that cannot be read at all is refused too rather than guessed. """ try: owner = doc.ObjectIdToObject(ent.OwnerID) is_layout = bool(owner.IsLayout) except Exception as exc: raise RuntimeError( f"Entity {handle} has no resolvable owner layout ({exc}); refusing to " "explode into a guessed space" ) from exc if not is_layout: try: name = str(owner.Name) except Exception: name = "?" raise RuntimeError( f"Entity {handle} is nested inside block definition {name!r}; explode the " "outer reference instead (exploding here would redefine the block under " "every other reference)" ) return owner def _require_block_defined(doc, name): """``doc.Blocks.Item(name)``, or a ``ValueError`` before ``InsertBlock`` runs. ``InsertBlock`` with an unknown name raises a bare COM error after the call has already been dispatched; refusing here names the block, costs one ``Blocks.Item`` probe and matches the headless engine's message. A layout block (``IsLayout``) cannot be inserted into itself — same refusal by name. Shared by ``block_insert`` and ``entity_create_block_ref``. """ if not isinstance(name, str) or not name.strip(): raise TypeError("block name must be a non-empty string") try: block = doc.Blocks.Item(name) except Exception as exc: raise ValueError(f"block {name!r} is not defined") from exc try: is_layout = bool(block.IsLayout) except Exception: is_layout = False if is_layout: raise ValueError(f"block {name!r} is a layout block and cannot be inserted") return block _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} #: `drawing_properties_*` field → `IAcadSummaryInfo` property. _SUMMARY_ATTRS = {field: field.capitalize() for field in SUMMARY_FIELDS} #: `IAcadSummaryInfo` failure codes, as the `scode` in a `com_error`'s #: excepinfo (measured on AutoCAD 2026; the description next to them is #: English while the HRESULT text is localised, so the code is matched first). _SUMMARYINFO_DUPLICATE_KEY = -2145386475 # AddCustomInfo: 'Duplicate key' _SUMMARYINFO_KEY_NOT_FOUND = -2145386476 # Set/RemoveCustomByKey: 'Key not found' def _summaryinfo_error_is(exc: BaseException, scode: int, description: str) -> bool: """True when `exc` is the `IAcadSummaryInfo` failure `scode` / `description`. `com_error.args[2]` is the excepinfo tuple ``(wCode, source, description, helpfile, helpcontext, scode)``; a missing or foreign excepinfo is never a match, so an unrelated COM failure keeps propagating. """ info = exc.args[2] if len(exc.args) > 2 else None if not isinstance(info, tuple): return False code = info[5] if len(info) > 5 else None desc = str(info[2] or "") if len(info) > 2 else "" return code == scode or desc.strip().casefold() == description.casefold() def _ensure_linetype_loaded(name: str) -> None: """If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0. Called by attribute setters so users can write `linetype="CENTER"` without having to remember to load it first. Must run on the COM thread. """ if not name or name.lower() in _BUILTIN_LINETYPES: return # Before any COM call: this name is about to be interpolated into a # SendCommand macro, where a newline or ';' would start a second command. name = sanitize_symbol_name(name, kind="linetype") doc = _acad_doc() existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)} if name.lower() in existing: return app = _acad_app() try: measurement = int(app.GetVariable("MEASUREMENT")) except Exception as exc: log.debug("MEASUREMENT read failed, defaulting to acadiso: %s", exc) measurement = 1 lin_file = "acadiso.lin" if measurement == 1 else "acad.lin" try: old_filedia = int(app.GetVariable("FILEDIA")) except Exception as exc: log.debug("FILEDIA read failed, assuming 1: %s", exc) old_filedia = 1 try: app.SetVariable("FILEDIA", 0) doc.SendCommand(f"_-LINETYPE _LOAD {name} {lin_file}\n\n") finally: try: app.SetVariable("FILEDIA", old_filedia) except Exception as exc: log.debug("FILEDIA restore failed: %s", exc) def _apply_entity_attrs(entity, layer: str | None, color: int | None, linetype: str | None): """Apply common entity attributes after creation.""" if layer is not None: entity.Layer = layer if color is not None: entity.Color = int(color) if linetype is not None: _ensure_linetype_loaded(linetype) entity.Linetype = linetype def _regen(): """Regen the active viewport so new entities appear immediately on screen.""" if _COM_STATE.get("batch_mode"): return try: _acad_doc().Regen(0) # 0 = acActiveViewport except Exception as exc: log.debug("Regen failed: %s", exc) # ── styles (track E): module helpers ───────────────────────────────────────── # # The ActiveX object model has no per-style getters or setters for dimension # variables: `AcadDimStyle` exposes `Name` and `CopyFrom` and nothing else. A # style's values are therefore written the way AutoCAD's own DIMSTYLE command # writes them — set the DIM* system variables on the document (which makes # them overrides of the *current* style) and `CopyFrom(document)` to save # those settings into the target style. Assigning `ActiveDimStyle` restores # that style's saved settings and discards unsaved overrides (AutoCAD's # `-DIMSTYLE Restore` rule; the fake in tests/test_styles.py models exactly # this, and scripts/smoke_settings_com.py confirms it live). Reading is # one-sided: `GetVariable` reads the *current* style only, so `dimstyle_list` # reports values for the current style and `values_available: False` for the # rest rather than cycling the active style behind the operator's back. def _com_named(collection, name: str): """A collection member by name, case-insensitive (AutoCAD's rule), or None.""" wanted = name.strip().lower() for index in range(collection.Count): item = collection.Item(index) if str(item.Name).lower() == wanted: return item return None def _com_dimvar_for_write(var: str, value: Any) -> Any: """ActiveX takes DIMDSEP and the arrowhead/text-style names as strings.""" if var in ("DIMDSEP", "DIMBLK", "DIMBLK1", "DIMBLK2", "DIMTXSTY"): return str(value) if isinstance(value, float): return float(value) return int(value) def _com_dimvar_for_report(var: str, raw: Any) -> Any: if var == "DIMDSEP": if isinstance(raw, int): return "," if raw == 0 else chr(raw) text = str(raw) return text[:1] if text else "," if var in ("DIMBLK", "DIMBLK1", "DIMBLK2"): # AutoCAD reports the block name (``_OBLIQUE``, ``""`` for closed # filled) — already canonical; the same read-side rule as ezdxf keeps # the two engines' rows spelled identically. from engineering.standards.dimstyles import reported_arrowhead return reported_arrowhead(raw) if var == "DIMTXSTY": return str(raw) return raw def _same_dimvar(old: Any, new: Any) -> bool: if isinstance(old, (int, float)) and isinstance(new, (int, float)): return abs(float(old) - float(new)) < 1e-9 return str(old) == str(new) #: ``SelectionSet.Select`` mode ``acSelectionSetAll`` (AcSelect enum): every #: entity in the database, in every layout — ``ssget "X"``. _AC_SELECTION_SET_ALL = 5 #: Characters an ``ssget`` filter string treats as wildcards (AutoCAD's #: ``wcmatch`` grammar). Symbol names may carry most of them: a dimension #: style called ``A.B`` also matched ``A_B``, ``N#1`` matched ``N21``, #: ``P[1]`` matched ``P1`` and ``S@X`` matched ``SAX`` until the name was #: escaped (measured live, AutoCAD 2026). _WCMATCH_SPECIALS = frozenset("*?#@.~[]-,`") def _wcmatch_literal(text: str) -> str: """``text`` spelled so an ``ssget`` filter matches it literally. Every wildcard-significant character is escaped with a backtick, the grammar's own escape. A symbol name can never contain a backtick itself (AutoCAD refuses one), so the escaped form is unambiguous; the match stays case-insensitive, as any symbol-table name comparison is. """ return "".join("`" + char if char in _WCMATCH_SPECIALS else char for char in text) def _com_dimensions_using(doc, style_name: str) -> list[str]: """Handles of every DIMENSION, in every layout, whose style is ``style_name``. One filtered ``SelectionSet.Select(acSelectionSetAll)`` — the ``ssget "X"`` route with ``(0 . "DIMENSION") (3 .