Files
autocad-mcp-pro/backends/base.py
T
U-C4N 9258da429f Add AutoCAD MCP premium skill and backend APIs
Introduce the autocad-mcp-premium skill with workflow docs, examples, and references (.claude/skills/autocad-mcp-premium/*). Add PlanSpec/critique plumbing and engineering helpers (engineering/plan_spec.py, engineering/critique.py) plus tests and a smoke script. Extend backend API (backends/base.py) with corner operations and premium meta-tools (trim/extend/fillet/chamfer, drawing_plan, drawing_critique, point_from_snap, construction_xline/clear, drawing_apply_iso_layers, dimension_auto, entity_select_smart) and implement COM backend corner ops (backends/com_backend.py) with placeholders for meta-tools. Update CLAUDE.md to document the premium drawing rules and standard workflow. Misc: add examples, references, and tests for premium features, and minor server/backend adjustments.
2026-05-15 19:17:14 +03:00

645 lines
21 KiB
Python

"""Abstract base class + shared data models for AutoCAD backends."""
from __future__ import annotations
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from engineering.plan_spec import (
CritiqueFocus,
DimStyle,
Issue,
LayerSetId,
PlanSpec,
SheetSize,
SnapType,
)
# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------
@dataclass
class CommandResult:
ok: bool
payload: Any = None
error: str | None = None
def to_dict(self) -> dict:
d: dict[str, Any] = {"ok": self.ok}
if self.ok:
d["payload"] = self.payload
else:
d["error"] = self.error
return d
@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 = ""
# ---------------------------------------------------------------------------
# Abstract backend
# ---------------------------------------------------------------------------
class AutoCADBackend(ABC):
"""All backends implement this interface."""
# ── identity ────────────────────────────────────────────────────────────
@property
@abstractmethod
def name(self) -> str: ...
@property
@abstractmethod
def is_connected(self) -> bool: ...
@abstractmethod
async def connect(self) -> None: ...
@abstractmethod
async def disconnect(self) -> None: ...
# ── drawing management ───────────────────────────────────────────────────
@abstractmethod
async def drawing_info(self) -> DrawingInfo: ...
@abstractmethod
async def drawing_new(self, template: str | None = None) -> dict: ...
@abstractmethod
async def drawing_open(self, path: str) -> dict: ...
@abstractmethod
async def drawing_save(self, path: str | None = None) -> dict: ...
@abstractmethod
async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict: ...
@abstractmethod
async def drawing_export_dxf(self, path: str) -> dict: ...
@abstractmethod
async def drawing_export_pdf(self, path: str) -> dict: ...
@abstractmethod
async def drawing_purge(self) -> dict: ...
@abstractmethod
async def drawing_audit(self) -> dict: ...
@abstractmethod
async def drawing_close(self, save: bool = True) -> dict: ...
@abstractmethod
async def drawing_undo(self) -> dict: ...
@abstractmethod
async def drawing_redo(self) -> dict: ...
# ── entity creation ──────────────────────────────────────────────────────
@abstractmethod
async def entity_create_line(
self, x1: float, y1: float, x2: float, y2: float,
z1: float = 0.0, z2: float = 0.0,
layer: str | None = None, color: int | None = None,
linetype: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_circle(
self, cx: float, cy: float, radius: float,
layer: str | None = None, color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_arc(
self, cx: float, cy: float, radius: float,
start_angle: float, end_angle: float,
layer: str | None = None, color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_polyline(
self, points: list[list[float]], closed: bool = False,
layer: str | None = None, color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_text(
self, text: str, x: float, y: float,
height: float = 2.5, rotation: float = 0.0,
layer: str | None = None, color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_mtext(
self, text: str, x: float, y: float,
width: float = 100.0, height: float = 2.5,
layer: str | None = None, color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_hatch(
self, pattern: str, boundary_points: list[list[float]],
scale: float = 1.0, angle: float = 0.0,
layer: str | None = None, color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_spline(
self, fit_points: list[list[float]],
layer: str | None = None, color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_ellipse(
self, cx: float, cy: float,
major_x: float, major_y: float, ratio: float = 0.5,
layer: str | None = None, color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_point(
self, x: float, y: float,
layer: str | None = None, color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_block_ref(
self, name: str, x: float, y: float,
scale_x: float = 1.0, scale_y: float = 1.0, rotation: float = 0.0,
layer: str | None = None,
) -> EntityInfo: ...
# ── dimensions ───────────────────────────────────────────────────────────
@abstractmethod
async def dimension_linear(
self, x1: float, y1: float, x2: float, y2: float,
dim_x: float, dim_y: float, rotation: float = 0.0,
layer: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_aligned(
self, x1: float, y1: float, x2: float, y2: float,
dim_x: float, dim_y: float,
layer: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_angular(
self, vx: float, vy: float,
x1: float, y1: float, x2: float, y2: float,
tx: float, ty: float,
layer: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_radius(
self, cx: float, cy: float, chord_x: float, chord_y: float,
leader_length: float = 10.0, layer: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_diameter(
self, x1: float, y1: float, x2: float, y2: float,
leader_length: float = 10.0, layer: str | None = None,
) -> EntityInfo: ...
# ── entity modification ──────────────────────────────────────────────────
@abstractmethod
async def entity_move(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> dict: ...
@abstractmethod
async def entity_copy(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> EntityInfo: ...
@abstractmethod
async def entity_rotate(
self, handle: str, base_x: float, base_y: float, angle_deg: float,
) -> dict: ...
@abstractmethod
async def entity_scale(
self, handle: str, base_x: float, base_y: float, factor: float,
) -> dict: ...
@abstractmethod
async def entity_mirror(
self, handle: str,
x1: float, y1: float, x2: float, y2: float,
delete_original: bool = False,
) -> EntityInfo: ...
@abstractmethod
async def entity_offset(
self, handle: str, distance: float,
side_x: float | None = None, side_y: float | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_delete(self, handle: str) -> dict: ...
@abstractmethod
async def entity_array_rectangular(
self, handle: str,
rows: int, cols: int, row_spacing: float, col_spacing: float,
) -> list[EntityInfo]: ...
@abstractmethod
async def entity_array_polar(
self, handle: str,
count: int, fill_angle: float,
center_x: float, center_y: float,
) -> list[EntityInfo]: ...
# ── corner operations (trim/extend/fillet/chamfer) ───────────────────────
@abstractmethod
async def entity_trim(
self, target_handle: str, cutter_handle: str,
keep_x: float, keep_y: float,
) -> EntityInfo:
"""Trim `target` against `cutter`; keep the segment containing
(keep_x, keep_y). Raises ToolError if no intersection."""
...
@abstractmethod
async def entity_extend(
self, target_handle: str, boundary_handle: str,
end_x: float | None = None, end_y: float | None = None,
) -> EntityInfo:
"""Extend `target` to meet `boundary`. If `end_x/y` is None, the
target endpoint nearest the boundary is auto-selected."""
...
@abstractmethod
async def entity_fillet(
self, handle1: str, handle2: str, radius: float,
trim: bool = True,
) -> EntityInfo:
"""Fillet two entities with the given radius. Returns the new ARC.
When `trim` is True (default), the source entities are shortened
to the tangent points (AutoCAD default behaviour)."""
...
@abstractmethod
async def entity_chamfer(
self, handle1: str, handle2: str,
dist1: float, dist2: float | None = None,
trim: bool = True,
) -> EntityInfo:
"""Chamfer two entities. When `dist2` is None it defaults to `dist1`
(symmetric chamfer). Returns the new chamfer LINE."""
...
# ── entity query/properties ───────────────────────────────────────────────
@abstractmethod
async def entity_get(self, handle: str) -> EntityInfo: ...
@abstractmethod
async def entity_set_properties(
self, handle: str,
layer: str | None = None,
color: int | None = None,
linetype: str | None = None,
lineweight: float | None = None,
visible: bool | None = None,
) -> dict: ...
@abstractmethod
async def entity_list(
self,
type_filter: str | None = None,
layer_filter: str | None = None,
limit: int = 200,
offset: int = 0,
) -> list[EntityInfo]: ...
# ── layer management ─────────────────────────────────────────────────────
@abstractmethod
async def layer_list(self) -> list[LayerInfo]: ...
@abstractmethod
async def layer_create(
self, name: str,
color: int = 7,
linetype: str = "Continuous",
lineweight: float = -3,
) -> LayerInfo: ...
@abstractmethod
async def layer_delete(self, name: str) -> dict: ...
@abstractmethod
async def layer_set_current(self, name: str) -> dict: ...
@abstractmethod
async def layer_modify(
self, name: str,
color: int | None = None,
linetype: str | None = None,
lineweight: float | None = None,
) -> LayerInfo: ...
@abstractmethod
async def layer_freeze(self, name: str) -> dict: ...
@abstractmethod
async def layer_thaw(self, name: str) -> dict: ...
@abstractmethod
async def layer_lock(self, name: str) -> dict: ...
@abstractmethod
async def layer_unlock(self, name: str) -> dict: ...
@abstractmethod
async def layer_hide(self, name: str) -> dict: ...
@abstractmethod
async def layer_show(self, name: str) -> dict: ...
# ── linetype management ──────────────────────────────────────────────────
@abstractmethod
async def linetype_list(self) -> list[str]: ...
@abstractmethod
async def linetype_load(
self, name: str, file: str | None = None,
) -> dict: ...
# ── block operations ─────────────────────────────────────────────────────
@abstractmethod
async def block_list(self) -> list[BlockInfo]: ...
@abstractmethod
async def block_insert(
self, name: str, x: float, y: float,
scale_x: float = 1.0, scale_y: float = 1.0, rotation: float = 0.0,
attributes: dict | None = None,
layer: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def block_explode(self, handle: str) -> dict: ...
@abstractmethod
async def block_get_attributes(self, handle: str) -> dict: ...
@abstractmethod
async def block_set_attributes(self, handle: str, attributes: dict) -> dict: ...
@abstractmethod
async def block_create_from_entities(
self, name: str, handles: list[str],
base_x: float = 0.0, base_y: float = 0.0,
) -> dict: ...
# ── analysis / query ─────────────────────────────────────────────────────
@abstractmethod
async def analysis_stats(self) -> dict: ...
@abstractmethod
async def analysis_entities_in_region(
self, x1: float, y1: float, x2: float, y2: float,
) -> list[EntityInfo]: ...
@abstractmethod
async def analysis_measure_distance(
self, x1: float, y1: float, x2: float, y2: float,
) -> float: ...
@abstractmethod
async def analysis_measure_area(self, points: list[list[float]]) -> float: ...
@abstractmethod
async def analysis_bounding_box(self) -> dict: ...
@abstractmethod
async def analysis_select_by_layer(self, layer_name: str) -> list[EntityInfo]: ...
@abstractmethod
async def analysis_select_by_type(self, entity_type: str) -> list[EntityInfo]: ...
# ── view / screenshot ────────────────────────────────────────────────────
@abstractmethod
async def view_zoom_extents(self) -> dict: ...
@abstractmethod
async def view_zoom_window(
self, x1: float, y1: float, x2: float, y2: float,
) -> dict: ...
@abstractmethod
async def view_screenshot(self) -> bytes | None: ...
# ── transactions ─────────────────────────────────────────────────────────
@abstractmethod
async def transaction_begin(self) -> dict: ...
@abstractmethod
async def transaction_commit(self) -> dict: ...
@abstractmethod
async def transaction_rollback(self) -> dict: ...
# ── system ───────────────────────────────────────────────────────────────
@abstractmethod
async def system_status(self) -> dict: ...
@abstractmethod
async def system_get_variable(self, name: str) -> Any: ...
@abstractmethod
async def system_set_variable(self, name: str, value: Any) -> dict: ...
@abstractmethod
async def system_run_command(self, command: str) -> dict: ...
@abstractmethod
async def system_run_lisp(self, expression: str) -> dict: ...
# ── premium meta-tools (planning + critique + snap + construction) ──────
@abstractmethod
async def drawing_plan(
self, intent: str,
sheet_size: "SheetSize" = "A3",
scale: float = 1.0,
layer_set_id: "LayerSetId" = "mech",
view_count: int = 1,
dim_style: "DimStyle" = "chain",
notes: list[str] | None = None,
) -> "PlanSpec":
"""Commit a drawing intent before any geometry is created.
Returns a PlanSpec replayed at finalize time by `drawing_critique`."""
...
@abstractmethod
async def drawing_critique(
self, focus: list["CritiqueFocus"] | None = None,
) -> list["Issue"]:
"""Run premium-quality checks; return zero issues before
`drawing_finalize`. `focus=None` runs all checks."""
...
@abstractmethod
async def point_from_snap(
self, handle: str, snap: "SnapType",
ref_x: float | None = None, ref_y: float | None = None,
) -> tuple[float, float]:
"""Return a deterministic snap point on `handle`.
snap ∈ {end, mid, center, quad, int, perp, near}.
For `near/perp/int`, `ref_x/y` is the reference point or other
entity's vicinity."""
...
@abstractmethod
async def construction_xline(
self, x: float, y: float, angle_deg: float,
layer: str = "CONSTRUCTION",
) -> EntityInfo:
"""Create an infinite construction line (XLINE) on a CONSTRUCTION
layer (auto-created with color 8 dashed if missing)."""
...
@abstractmethod
async def construction_clear(
self, layer: str = "CONSTRUCTION",
) -> dict:
"""Delete every entity on the CONSTRUCTION layer.
Must be called before `drawing_finalize`."""
...
@abstractmethod
async def drawing_apply_iso_layers(
self, standard: "LayerSetId" = "mech",
) -> dict:
"""Bootstrap a full ISO-conformant layer set with correct colors
and lineweights. Idempotent."""
...
@abstractmethod
async def dimension_auto(
self, handles: list[str], style: "DimStyle" = "chain",
offset: float = 10.0,
) -> list[EntityInfo]:
"""Generate ISO 129 dimensions across `handles` in the chosen style
(chain | baseline | ordinate). `offset` is the dimension-line
distance from the reference geometry (mm)."""
...
@abstractmethod
async def entity_select_smart(
self, predicate: dict,
) -> list[EntityInfo]:
"""Semantic entity selection. Predicate keys (all optional, AND-ed):
- type: "LINE" | "CIRCLE" | ...
- layer: layer name
- near: [x, y, radius] — entity bounding box must intersect circle
- length_range: [min, max] — applies to LINE/ARC
- color: ACI int
"""
...
# ── concrete helpers (use existing primitives) ──────────────────────────
async def set_layer_active(self, name: str) -> None:
"""Convenience: alias for layer_set_current. Concrete; backends need not override."""
await self.layer_set_current(name)
async def ensure_linetypes(
self, names: list[str], file: str | None = None,
) -> dict[str, str]:
"""Idempotently load each linetype in `names`. Returns {name: status}.
Status is one of: 'already_loaded', 'loaded', 'failed: <msg>'.
"""
try:
current = {ln.lower() for ln in await self.linetype_list()}
except Exception:
current = set()
results: dict[str, str] = {}
for n in names:
if n.lower() in current:
results[n] = "already_loaded"
continue
try:
await self.linetype_load(n, file=file)
results[n] = "loaded"
except Exception as exc:
results[n] = f"failed: {exc}"
return results
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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