Files
autocad-mcp-pro/backends/contracts/analysis.py
T
Umut CANandClaude Opus 5 ca1c79fd01
Release / test (full, windows-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.12) (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
fix: seven defects an adversarial audit found, and the gates that missed them
Every one of these was reported ok/clean to the caller while being wrong.

Silently wrong numbers
- selection_filter(min_area) read `entity.dxf.get("area", 0.0)` for SOLID,
  TRACE and HATCH, none of which carry that attribute, so every hatch
  measured 0.0. A 1600 mm^2 hatch was excluded by min_area=1.0 while
  `filtered_by` asserted the filter had run, and COM (which reads ActiveX
  .Area) returned a different selection for the same call. It now answers
  from the measurement `entity_measure` uses, so the two cannot disagree.
- Every headless dimension rendered through ezdxf's bare `Standard` style:
  1.0 mm text with a comma decimal marker, against ISO 3098's 2.5 mm minimum
  and ISO 129's point -- while the document header itself said $DIMTXT 2.5.
  `ezdxf.new(setup=True)` is not the fix; measured, it renders a 100 mm
  dimension as "10000". The attributes are now stored explicitly.
- Saving writes `dimrnd = 0.0` onto the Standard style, and ezdxf applies
  `xround(value, dimrnd)` whenever the attribute is present at all --
  `xround(33.333, 0.0)` is 33. So on any reopened file, including one this
  server saved, every dimension was rounded to a whole unit before DIMDEC
  formatted it: a 12.75 mm feature dimensioned 13. AutoCAD means DIMRND 0 as
  *no* rounding; the sentinel is now discarded at every load site, and a real
  0.5 mm rounding is left alone.
- The DIM* header fold gave out-of-range values teeth they did not have when
  they died in the header: dim_decimals=-1 returned ok and then killed the
  next dimension with `ValueError: Format specifier missing precision` from
  inside ezdxf, and 40 printed forty digits of float noise onto the sheet.
  Ranges are now AutoCAD's own (DIMDEC 0-8, DIMZIN 0-15, lengths positive).

Security
- security.py recorded the policy as "sanitize_command and sanitize_lisp each
  have exactly ONE caller ... the policy is scoped to the CHANNEL". The
  premise was false. `_ensure_linetype_loaded` and `ComBackend.linetype_load`
  build `_-LINETYPE _LOAD {name} {file}` and hand it to SendCommand, which
  treats a newline and ';' as segment separators -- so a `linetype=` argument
  carrying either injected further AutoCAD commands, and the 36-verb denylist
  never saw that channel. Guarded with the DXF symbol-name rule (the
  injection characters fall out of it rather than being enumerated) plus
  validate_path for the file argument, which was also the one path-taking
  tool reaching the filesystem unchecked. The policy comment is corrected.

Gates that could not see
- drawing_critique returned zero issues on a quarantined document: all seven
  checks wrapped their backend call in a bare `except Exception`. CLAUDE.md
  rule 7 makes that call the gate an agent is told to trust. Only the
  quarantine now escapes; an ordinary broken check still degrades.
  `_check_dimension_legibility` also read `_doc` directly from the event loop
  thread, missing the gate and performing exactly the unsynchronised access
  the quarantine exists to prevent. `construction_clear` answered
  "deleted: 0" -- there was nothing to clear -- for the same reason.
- The iso128 focus checked layer lineweights only, so a sheet lettered at
  1.0 mm critiqued clean. It now reads the effective dimension height and
  separator, header-then-style, in the order the renderer resolves.
- check_doc_numbers.py derived five values it never checked, and those were
  the ones that had drifted. The collected-test count (README said 1274, the
  tree had 1369) and the A/B pass-rate and Fixed columns are now gated.
- benchmarks/README.md's v1.4.0 table was impossible on its face -- 21
  passing plus 5 fixed is 26, not 24 -- and contradicted the report it
  linked, because `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that
  already carried the dimension fixes and so never measured the v1.5.0 tag.
  Replaced with a real run: ab-v1.4.0-vs-v1.5.1.json, 21/26 -> 26/26.

Release gate
- release.yml ran one ubuntu/3.11 lane, so a Windows-only defect could reach
  PyPI with no Windows job having executed. That is not hypothetical: v1.5.0
  shipped `except pywintypes.com_error` raising NameError wherever pywin32 is
  absent. It now runs the same three lanes as ci.yml, Windows included.

1368 passed, 1 xfailed. ruff clean. Tool inventory and all 16 gated document
numbers match the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:07:50 +03:00

176 lines
7.2 KiB
Python

"""Measurement and analysis.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from backends.capability import capability
if TYPE_CHECKING:
from backends.base import EntityInfo
class AnalysisContract(ABC):
# ── boundary tracing (M8 / F3) ───────────────────────────────────────────
@capability( # noqa: B027 — @capability supplies the body
"boundary_trace",
reason=(
"ActiveX exposes no BOUNDARY member; the live path would have to drive the "
"-BOUNDARY command blind, which has not been verified against a live AutoCAD. "
"Use the headless backend (AUTOCAD_MCP_BACKEND=ezdxf), or run BOUNDARY in "
"AutoCAD directly."
),
)
async def boundary_trace(
self, x: float, y: float, layer: str | None = None, tolerance: float = 1e-9
) -> dict:
"""AutoCAD's BOUNDARY/BPOLY: trace the closed loop enclosing a seed point.
Returns ``{ok, handle, vertices, area, closed, source_handles}``.
``vertices`` is a count, ``area`` is unsigned.
Implementations must not enumerate every loop in the drawing:
``edgeminer.find_all_loops`` is O(n!) and was measured at 63.9 s for 40
edges and a timeout at 60. The seed point picks a starting edge and one
loop is walked from it.
A seed with no enclosing loop is refused. Returning an empty boundary
would be a boundary that bounds nothing, reported as success.
"""
...
@capability( # noqa: B027 — @capability supplies the body
"boundary_trace",
reason=(
"Chaining arbitrary entities into a loop needs the same edge graph BOUNDARY "
"does, which ActiveX does not expose. Use the headless backend "
"(AUTOCAD_MCP_BACKEND=ezdxf)."
),
)
async def boundary_from_entities(self, handles: list[str], tolerance: float = 1e-9) -> dict:
"""Chain the given entities into one closed polyline.
Returns ``{ok, handle, vertices, area, closed}``. Refuses a chain that
does not close, naming the coordinates of the endpoint that dangles —
"these do not form a loop" is not actionable without the gap.
"""
...
@abstractmethod
async def analysis_list_properties(self, handle: str) -> dict:
"""AutoCAD's LIST: the full DXF attribute set for one handle.
Returns ``{ok, handle, type, layer, properties, dxf_attributes}``.
``properties`` mirrors what ``entity_get`` reports; ``dxf_attributes``
is the raw attribute set that ``entity_get`` deliberately does not
carry.
Two things the raw dump cannot be handed over as-is. It holds ``Vec3``
values, so it is not JSON-serialisable and would die at the wire
boundary; and its coordinates are stored in the entity's own frame, so
for anything with an extrusion they would contradict every other
coordinate this server reports. Both are converted here, and
``extrusion`` stays in the dump so the original frame is still visible.
"""
...
@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 entity_measure(self, handle: str, flatten_tolerance: float = 0.001) -> dict:
"""Area and perimeter of ONE existing entity, addressed by handle.
Returns ``{"handle", "type", "area", "perimeter", "closed",
"assumed_closed", "method", "exact", "flatten_tolerance", "backend",
"self_intersecting", "perimeter_exact", "loop_count"}``.
A HATCH is measured as the area it *fills* — outer loops minus the
islands inside them — and the headless engine adds ``hatch_style``
(``normal``/``outer``/``ignore``) so the number can be read against the
drawing's own island setting. The live backend does not carry that key:
it reads ActiveX ``.Area``, which **does** apply the island style —
verified on a live AutoCAD 2026 (2026-08-06): a hatch of r20 with an
r10 island returns 942.478, which is pi*(400-100).
``perimeter`` is ``None`` where ActiveX exposes no length member for the
type, and ``perimeter_exact`` is then False. The member differs per
type and was measured rather than assumed: LWPOLYLINE ``.Length``,
CIRCLE ``.Circumference``, REGION ``.Perimeter``, HATCH none. A
3DSOLID has no ``.Area`` at all — only ``.Volume`` — and is refused by
name.
A missing perimeter is not a reason to discard an area that was read
successfully — doing exactly that is what made this method raise on
every ACIS type it advertised.
``exact`` and
``flatten_tolerance`` are the accuracy disclosure: when one engine can
answer in closed form and the other has to flatten, the payload says so
rather than leaving two numbers to quietly disagree.
Two different refusals, and the difference matters. A type that bounds no
area at all (LINE, TEXT, a 3D polyline) raises a plain ``RuntimeError``:
no engine can measure it, so tagging a capability would falsely suggest
switching backends helps. A type this *engine* cannot evaluate (ACIS
solids headlessly) raises ``UnsupportedCapabilityError``, which names the
engine that can.
"""
...
@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]: ...
@abstractmethod
async def selection_get(self) -> dict:
"""Read the live viewport's implied ("pickfirst") selection set.
COM-only / meaningful only with live AutoCAD. Returns a dict::
{
"ok": bool, # True on COM even for an empty pick
"count": int,
"handles": list[str], # entity handles to act on
"entities": list[EntityInfo], # _dc()-converted by the server
"pickfirst": bool | None, # state of the PICKFIRST sysvar
"message" / "error": str, # optional guidance / failure reason
}
The ezdxf headless backend has no viewport, so it returns ``ok=False``
with an empty ``handles`` list (same shape, never raises).
"""
...