"""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: '. """ 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