Files
autocad-mcp-pro/backends/base.py
T
Umut CANandClaude Opus 5 3ef316d520 feat(refs): xrefs, raster underlays, data extract and real DWG output
SECTION 24 completes at eleven tools with xref_attach, xref_manage,
image_attach, data_extract and drawing_export_dwg, on the four contract members
that genuinely differ per engine -- backends/contracts/refs.py. Everything else
in the sheet track composes the generic members and is written once.

The ActiveX members were read off the AutoCAD 2026 seat's own type library
(C:\Program Files\Common Files\Autodesk Shared\acax25enu.tlb, "AutoCAD 2025
Type Library" 1.0, the library the running 25.x seat registers) before a line of
the COM path was written, and the measurement is in the code beside the calls:

  IAcadModelSpace.AttachExternalReference(PathName, Name, InsertionPoint,
      Xscale, Yscale, Zscale, Rotation, bOverlay, Password)
  IAcadModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor,
      RotationAngle)
  IAcadBlock: IsXRef (get), Path (get AND put -- so the repath is an assignment,
      not a method), Reload(), Unload(), Bind(bPrefixName), Detach(),
      XRefDatabase (get)
  IAcadDocument.SaveAs(FullFileName, SaveAsType, vSecurityParams)
  AcSaveAsType: ac2000_dwg=12 ac2004_dwg=24 ac2007_dwg=36 ac2010_dwg=48
      ac2013_dwg=60 ac2018_dwg=64 (=acNative)

The running seat itself was not answering COM while this landed (its window was
Not Responding and GetActiveObject never returned), so the type library -- which
is the same seat's declared surface and reads without a call into the process --
is what the members are measured from; no document of the operator's was opened
or touched. This repository has been bitten four times by a fake that invented a
member, so the COM fakes in the tests model those names and nothing else.
ActiveX angles are radians, so the degree-to-radian conversion happens at that
boundary and nowhere else.

Headlessly, ezdxf.xref.attach carries the attachment (measured on ezdxf 1.4.4:
block flags 20 for an attachment and 24 for an overlay, so "is this an xref" is
flags & (BLK_XREF | BLK_XREF_OVERLAY) -- masking BLK_XREF alone misses every
overlay), add_image_def + add_image the raster, with Pillow reading the pixel
size so the placed image keeps the file's aspect.

Three capability keys, declared in both maps:

  dwg_write  -- measured absent here (odafc.is_installed() is False), so the
                ezdxf entry is a runtime re-evaluation like viewport_render's
                matplotlib check rather than a hardcoded False: installing the
                ODA File Converter starts the call working with no edit to the
                map. The refusal names the install route and drawing_export_dxf.
  xref_live  -- reload and bind need a live seat's xref manager and are refused
                headlessly; list, detach and path are headless and are not
                behind the key.
  xlsx_write -- openpyxl is optional and absent here; CSV is always available,
                so the refusal never blocks the deliverable and says so.

Two numbers the plan predicted wrongly are corrected to what the registry
actually does. `block` outranks `drawing` in _GROUP_TAG_PRIORITY, so the two
xref tools file under `blocks` (9 -> 11) beside block_insert -- which is where
an xref belongs, being a block definition that lives in another file -- and
`drawing` moves 11 -> 12, not 11 -> 14. And `data_extract` must not say "parts
list" in its own text: that is the golden discovery query for
entity_create_table, and a file writer outranking the tool that draws the thing
is a regression the tuning bar catches. It is described by its destination --
a file -- instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 23:03:57 +03:00

224 lines
5.8 KiB
Python

"""Abstract base class + shared data models for AutoCAD backends.
The interface itself lives in :mod:`backends.contracts`, one module per domain;
this module owns the shared data models, the refusal vocabulary, and the
composition that ties the contracts together. Callers import
``AutoCADBackend`` from here exactly as before.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from backends.capability import ( # noqa: F401 — deliberate re-export, see below
UnsupportedCapabilityError,
_unsupported,
)
from backends.contracts import (
AnalysisContract,
BlockContract,
CornerOpsContract,
DimensionContract,
DrawingContract,
EntityCreationContract,
EntityModificationContract,
EntityQueryContract,
EnvironmentContract,
GdtContract,
IdentityContract,
LayerContract,
LayoutContract,
LinetypeContract,
PageSetupContract,
PremiumContract,
RefsContract,
SettingsContract,
SolidContract,
StylesContract,
SystemContract,
TransactionContract,
ViewContract,
)
if TYPE_CHECKING:
pass
# The refusal vocabulary lives in ``backends.capability`` — the contracts need
# it and this module imports the contracts, so defining it here would be a
# cycle. Re-exported so every existing
# ``from backends.base import UnsupportedCapabilityError`` keeps working and
# there is still exactly one class object in play.
# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------
@dataclass
class EntityInfo:
handle: str
type: str # e.g. "LINE", "CIRCLE", "ARC"
layer: str
color: int # ACI color (256=ByLayer)
linetype: str
visible: bool
properties: dict = field(default_factory=dict)
@dataclass
class LayerInfo:
name: str
color: int
linetype: str
lineweight: float
is_on: bool
is_frozen: bool
is_locked: bool
is_current: bool
@dataclass
class BlockInfo:
name: str
origin: tuple[float, float]
attribute_count: int
entity_count: int
is_xref: bool
description: str = ""
@dataclass
class DrawingInfo:
name: str
full_path: str
saved: bool
entity_count: int
layer_count: int
block_count: int
extents_min: tuple[float, float]
extents_max: tuple[float, float]
units: str
version: str = ""
backend: str = ""
@dataclass(frozen=True)
class FeatureCapability:
supported: bool
mode: str | None = None
reason: str | None = None
def to_dict(self) -> dict:
return {
"supported": self.supported,
"mode": self.mode,
"reason": self.reason,
}
@dataclass(frozen=True)
class CapabilityMap:
backend: str
features: dict[str, FeatureCapability]
def to_dict(self) -> dict:
return {
"backend": self.backend,
"features": {name: value.to_dict() for name, value in self.features.items()},
}
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def normalize_lineweight(lw: float | int | None) -> int | None:
"""Coerce a lineweight to ezdxf/COM hundredths-of-a-mm integers.
AutoCAD and ezdxf store lineweights as integer hundredths (25 == 0.25 mm),
plus the -1/-2/-3 sentinels (ByLayer/ByBlock/Default). Callers, however,
mix conventions: the MCP tool boundary passes hundredths (25) while the
engineering layer sets pass millimetres (0.25). The old ``int(0.25)``
silently truncated millimetre values to 0 and wiped the lineweight (and with
it the whole ISO 128 discipline). Disambiguation is unambiguous because no
ISO 128 millimetre value exceeds 2.0 and no valid hundredth is below 5, so
the ``(0, 2.05]`` band is always millimetres.
"""
if lw is None:
return None
try:
v = float(lw)
except (TypeError, ValueError):
return lw # leave exotic / already-int values untouched
if v < 0:
return int(round(v)) # -1/-2/-3 sentinels
if v == 0:
return 0
if v <= 2.05:
return int(round(v * 100.0)) # millimetres -> hundredths
return int(round(v)) # already hundredths
# ---------------------------------------------------------------------------
# Abstract backend
# ---------------------------------------------------------------------------
class AutoCADBackend(
IdentityContract,
DrawingContract,
LayoutContract,
PageSetupContract,
SolidContract,
EntityCreationContract,
DimensionContract,
EntityModificationContract,
CornerOpsContract,
EntityQueryContract,
LayerContract,
LinetypeContract,
BlockContract,
AnalysisContract,
ViewContract,
TransactionContract,
SystemContract,
PremiumContract,
GdtContract,
SettingsContract,
StylesContract,
EnvironmentContract,
RefsContract,
):
"""All backends implement this interface.
The methods live in :mod:`backends.contracts`, one module per domain.
This class is the composition and the import surface: everything that
used to do ``from backends.base import AutoCADBackend`` still works, and
``isinstance`` against it is unchanged.
"""
def shoelace_area(points: list[list[float]]) -> float:
"""Calculate polygon area using the shoelace formula."""
n = len(points)
if n < 3:
return 0.0
area = 0.0
for i in range(n):
j = (i + 1) % n
area += points[i][0] * points[j][1]
area -= points[j][0] * points[i][1]
return abs(area) / 2.0
def deg2rad(degrees: float) -> float:
return degrees * math.pi / 180.0
def rad2deg(radians: float) -> float:
return radians * 180.0 / math.pi