Tool count 131 -> 154; 474 -> 1242 collected tests. The release exists for two measured reasons. A 149-tool catalog costs a client ~40,300 tokens before it has asked for anything, and a drafter searching the way drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock index, because those words were simply not in the data. Discovery and batching - DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool, ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised. - cad_batch runs a step list in one round trip; fields= projects result-heavy responses. The `core` profile is gone -- discovery replaced it. Honesty debt (Tier 0) - OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and rotated geometry reported coordinates that were quietly wrong. backends/ocs.py translates at the backend boundary; every tool coordinate is WCS on both engines, and a tilted plane refuses rather than answering with a number that looks fine. - drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never repaired. analysis_measure_area shoelaced points the caller typed in. - ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a server whose document lock is a single asyncio.Lock. Architecture - The 1854-line backend ABC is 19 contract modules; @capability(key, reason=) makes optional methods declarative and a gate fails on undeclared holes. - 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch. - Wave A: selection filters, boundary tracing, hatch depth, annotation objects, measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann substitute loses 28.2% of the area on a square with one semicircular edge. Verified against a live AutoCAD 2026, which found four COM defects no amount of reading would have caught -- AcadPViewport has no ViewCenter, and the line that set it had been swallowed by a bare except since v1.4. Benchmark v3 (15 tasks) and what it caught - v2 scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five tasks were added because they can fail, and three did while being written -- a hatch could not be measured though the refusal listed HATCH as measurable, the boundary tools and the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a circle stored as two bulged vertices measured 0.0. - Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and were never asked these five questions; the chart shows those rows `not run`. - Correctness A/B: 21/24 -> 24/24, zero regressions. Performance, stated plainly The published v1.4 perf report was recorded on CPython 3.14 and the new one on 3.11.15, so the apparent 2-6x speedup is the interpreter, not this release. Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22% slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is documented, and winning it back is on the 1.6 roadmap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
5.6 KiB
Python
216 lines
5.6 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,
|
|
GdtContract,
|
|
IdentityContract,
|
|
LayerContract,
|
|
LayoutContract,
|
|
LinetypeContract,
|
|
PremiumContract,
|
|
SettingsContract,
|
|
SolidContract,
|
|
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,
|
|
SolidContract,
|
|
EntityCreationContract,
|
|
DimensionContract,
|
|
EntityModificationContract,
|
|
CornerOpsContract,
|
|
EntityQueryContract,
|
|
LayerContract,
|
|
LinetypeContract,
|
|
BlockContract,
|
|
AnalysisContract,
|
|
ViewContract,
|
|
TransactionContract,
|
|
SystemContract,
|
|
PremiumContract,
|
|
GdtContract,
|
|
SettingsContract,
|
|
):
|
|
"""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
|