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
224 lines
5.8 KiB
Python
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
|