Files
autocad-mcp-pro/tests/test_entity_measure.py
T
Umut CANandClaude Opus 5 b03a7615b1 release: v1.5.0 — discovery layer, honesty debt closed, Wave A
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>
2026-08-06 09:45:20 +03:00

268 lines
11 KiB
Python

"""T0.5 — ``entity_measure``: ask the drawing, not the model's memory.
Companion to ``tests/test_measure.py``, which covers the arithmetic. This file
covers the part that was actually missing: addressing an entity **by handle**.
Before this, neither backend could answer "what is the area of the polyline I
just drew". ``analysis_measure_area`` shoelaced points the caller supplied, and
the live COM backend did the same — despite having a document, a
``HandleToObject`` lookup it already used fourteen times, and an ActiveX ``Area``
property sitting right there.
Two distinctions here are load-bearing and are asserted, not assumed:
* A LINE has no area on *any* engine, so that is a plain error — tagging it with
a ``capability`` would falsely imply "switch backends and this works".
* A REGION has no area on the *headless* engine only, so that is a capability
refusal naming the live backend as the way forward.
"""
from __future__ import annotations
import math
import pytest
from backends.base import UnsupportedCapabilityError
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
#: 100x100 square, bottom edge bulged outward into a semicircle of radius 50.
BULGED_SQUARE = [(0, 0, 0, 0, 0.0), (100, 0, 0, 0, 1.0), (100, 100, 0, 0, 0.0), (0, 100, 0, 0, 0.0)]
TRUE_AREA = 10000.0 + math.pi * 50.0**2 / 2.0
TRUE_PERIMETER = 300.0 + math.pi * 50.0
def _add_bulged_square(backend, close=True):
pline = backend._doc.modelspace().add_lwpolyline(BULGED_SQUARE, format="xyseb", close=close)
return pline.dxf.handle
# ── the capability that did not exist ───────────────────────────────────────
async def test_measures_a_bulged_polyline_exactly(backend):
"""The 28.2% the reported-points workflow lost."""
handle = _add_bulged_square(backend)
result = await backend.entity_measure(handle)
assert result["handle"] == handle
assert result["type"] == "LWPOLYLINE"
assert result["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
assert result["perimeter"] == pytest.approx(TRUE_PERIMETER, rel=1e-9)
assert result["method"] == "analytic_bulge"
assert result["exact"] is True
assert result["flatten_tolerance"] is None
assert result["closed"] is True
assert result["assumed_closed"] is False
assert result["self_intersecting"] is False
assert result["backend"] == "ezdxf"
async def test_a_straight_polyline_reports_the_plain_analytic_method(backend):
pline = backend._doc.modelspace().add_lwpolyline(
[(0, 0), (10, 0), (10, 10), (0, 10)], close=True
)
result = await backend.entity_measure(pline.dxf.handle)
assert result["area"] == pytest.approx(100.0)
assert result["perimeter"] == pytest.approx(40.0)
assert result["method"] == "analytic"
assert result["exact"] is True
async def test_measures_a_circle(backend):
circle = await backend.entity_create_circle(5, 5, 4)
result = await backend.entity_measure(circle.handle)
assert result["area"] == pytest.approx(math.pi * 16.0)
assert result["perimeter"] == pytest.approx(2 * math.pi * 4.0)
assert result["method"] == "analytic"
assert result["exact"] is True
assert result["closed"] is True
async def test_an_open_boundary_says_it_was_closed(backend):
"""AutoCAD's AREA closes an open boundary; the payload must admit it."""
handle = _add_bulged_square(backend, close=False)
result = await backend.entity_measure(handle)
assert result["closed"] is False
assert result["assumed_closed"] is True
assert result["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
async def test_a_self_intersecting_polyline_is_flagged(backend):
"""Shoelace cancels crossed lobes, so the 0.0 has to come with a warning."""
pline = backend._doc.modelspace().add_lwpolyline(
[(0, 0), (100, 100), (100, 0), (0, 100)], close=True
)
result = await backend.entity_measure(pline.dxf.handle)
assert result["self_intersecting"] is True
# ── the two kinds of "no" ───────────────────────────────────────────────────
async def test_a_line_has_no_area_on_any_engine_so_it_is_not_a_capability_gap(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
with pytest.raises(RuntimeError) as excinfo:
await backend.entity_measure(line.handle)
assert not isinstance(excinfo.value, UnsupportedCapabilityError), (
"tagging this `capability` would tell the user to switch backends, "
"where no backend can give a LINE an area"
)
assert "LWPOLYLINE" in str(excinfo.value), "the refusal must list what IS measurable"
async def test_a_region_is_a_real_capability_boundary(backend):
"""ezdxf stores ACIS opaquely and cannot evaluate it; live AutoCAD can."""
region = backend._doc.modelspace().new_entity("REGION", {})
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.entity_measure(region.dxf.handle)
assert excinfo.value.capability == "measure_area_acis"
assert "com" in str(excinfo.value).lower(), "a refusal must name the way forward"
async def test_measure_capability_is_declared_on_both_backends():
assert EzdxfBackend().capabilities().features["measure_area_acis"].supported is False
assert ComBackend().capabilities().features["measure_area_acis"].supported is True
# ── HATCH: the type the refusal advertised and then refused ─────────────────
#
# `_MEASURABLE_TYPES` named HATCH, so a caller who asked for a hatch's area got
# a RuntimeError whose own text listed HATCH as measurable. Whichever half was
# right, the pair could not both be — and a hatch is the one entity whose area
# a drafter actually asks for, because it is the section they have to fill.
def _hatch_with_island(backend, *, style: int = 0):
"""20x20 outer square, 10x10 island. Filled area is 400 - 100 = 300."""
hatch = backend._doc.modelspace().add_hatch(color=1)
hatch.dxf.hatch_style = style
hatch.paths.add_polyline_path(
[(0, 0), (20, 0), (20, 20), (0, 20)], is_closed=True, flags=1 | 16
)
island = hatch.paths.add_edge_path(flags=0)
island.add_line((5, 5), (15, 5))
island.add_line((15, 5), (15, 15))
island.add_line((15, 15), (5, 15))
island.add_line((5, 15), (5, 5))
return hatch.dxf.handle
async def test_a_hatch_reports_the_area_it_actually_fills(backend):
handle = _hatch_with_island(backend)
result = await backend.entity_measure(handle)
assert result["type"] == "HATCH"
assert result["area"] == pytest.approx(300.0, abs=1e-6), (
"the island is a hole in the fill; counting it is the 33% error"
)
assert result["loop_count"] == 2
assert result["hatch_style"] == "normal"
assert result["exact"] is True, "every edge here is a straight line — nothing was flattened"
async def test_hatch_style_ignore_fills_straight_over_the_island(backend):
"""AutoCAD's HPISLAND=2 fills the island too. The number must follow."""
handle = _hatch_with_island(backend, style=2)
result = await backend.entity_measure(handle)
assert result["area"] == pytest.approx(400.0, abs=1e-6)
assert result["hatch_style"] == "ignore"
async def test_a_nested_island_alternates_under_the_normal_style(backend):
"""Depth 2 is where `normal` and `outer` stop agreeing, so it is the only
case that proves the depths were computed rather than assumed."""
hatch = backend._doc.modelspace().add_hatch(color=1)
hatch.dxf.hatch_style = 0 # normal
hatch.paths.add_polyline_path(
[(0, 0), (20, 0), (20, 20), (0, 20)], is_closed=True, flags=1 | 16
)
hatch.paths.add_polyline_path([(4, 4), (16, 4), (16, 16), (4, 16)], is_closed=True, flags=0)
hatch.paths.add_polyline_path([(8, 8), (12, 8), (12, 12), (8, 12)], is_closed=True, flags=0)
result = await backend.entity_measure(hatch.dxf.handle)
# 400 - 144 + 16: the innermost loop is filled again.
assert result["area"] == pytest.approx(272.0, abs=1e-6)
assert result["loop_count"] == 3
async def test_a_curved_hatch_edge_says_it_was_flattened(backend):
hatch = backend._doc.modelspace().add_hatch(color=1)
hatch.paths.add_edge_path(flags=1 | 16).add_arc((0, 0), 10, 0, 360)
result = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-5)
assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-3)
assert result["exact"] is False, "a flattened arc is not an exact area"
assert result["flatten_tolerance"] == 1e-5
async def test_tightening_the_tolerance_does_not_make_a_curved_hatch_exact(backend):
"""The floor is the arc-to-Bezier conversion, not the flattening.
A caller who sees `flatten_tolerance` naturally assumes a smaller number
buys a better area, all the way to the truth. For hatch edges it does not:
ezdxf hands the boundary over as cubic Beziers, and tightening the
tolerance converges on the *Bezier's* area, which sits ~0.028% above the
circle it stands in for. The residual does not shrink with the tolerance,
which is why `exact` is False rather than the tolerance being presented as
the accuracy knob.
"""
hatch = backend._doc.modelspace().add_hatch(color=1)
hatch.paths.add_edge_path(flags=1 | 16).add_arc((0, 0), 10, 0, 360)
true_area = math.pi * 100.0
coarse = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-3)
fine = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-7)
finest = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-9)
# Four orders of magnitude of extra tolerance buy nothing after the first.
assert finest["area"] == pytest.approx(fine["area"], rel=1e-7)
residual = abs(finest["area"] - true_area) / true_area
assert residual > 2e-4, "the Bezier error survives any tolerance"
assert abs(coarse["area"] - true_area) / true_area < residual, (
"the coarse run is only closer by accident — an inscribed chord error "
"that happens to cancel part of the Bezier overshoot"
)
assert finest["exact"] is False
# ── entity_get stops dropping the curve ─────────────────────────────────────
async def test_entity_get_reports_area_and_length_for_a_closed_polyline(backend):
"""``props["length"] = ent.length()`` was dead code — LWPolyline has no
``length()``, so the AttributeError went to a debug log and the key silently
never appeared."""
handle = _add_bulged_square(backend)
info = await backend.entity_get(handle)
assert info.properties["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
assert info.properties["length"] == pytest.approx(TRUE_PERIMETER, rel=1e-9)
async def test_entity_get_still_reports_only_xy_points(backend):
"""The bulge feeds the measurement; it must not change the points contract."""
handle = _add_bulged_square(backend)
info = await backend.entity_get(handle)
assert all(len(point) == 2 for point in info.properties["points"])