Files
autocad-mcp-pro/tests/test_annotation_objects.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

221 lines
9.0 KiB
Python

"""M8/F15 — annotation objects: wipeout, revision cloud, MTEXT mask, find/replace.
Four tools that fill real gaps rather than restating the 141 already here. Each
carries a measured caveat:
* **WIPEOUT** is a real DXF entity (`add_wipeout`) that round-trips.
* **REVCLOUD** is genuinely available: `ezdxf.revcloud.add_entity()` emits the
bulged LWPOLYLINE that AutoCAD's REVCLOUD *is*, stamps the `RevcloudProps`
xdata and picks the bulge sign from the winding direction — so the result is
recognised by `is_revcloud()` rather than merely resembling a cloud. The trap
is the segment length: pass one larger than the shortest edge and you get a
"cloud" with no arcs at all, which looks like a plain rectangle and reports
success.
* **MTEXT background mask** round-trips (`bg_fill`, `box_fill_scale`) — proven
through saveas/readfile, because a mask that vanishes on save is worse than
no mask.
* **find/replace** is the one with a real scope problem. TEXT, MTEXT, ATTRIB
and ATTDEF are all readable *and* writable, so all four are in scope. A
DIMENSION's `dxf.text` is `'<>'` — the override placeholder, not the
measurement — so replacing text inside dimensions would either do nothing or
destroy the association with the measured value. The tool reports which types
it searched instead of silently skipping some, because "0 replacements" and
"I did not look there" are different answers.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
SQUARE = [[0.0, 0.0], [100.0, 0.0], [100.0, 60.0], [0.0, 60.0]]
# ── wipeout ─────────────────────────────────────────────────────────────────
async def test_a_wipeout_is_created_and_reports_its_handle(backend):
result = await backend.entity_create_wipeout(SQUARE)
assert result["ok"] is True
entity = backend._doc.entitydb.get(result["handle"])
assert entity.dxftype() == "WIPEOUT"
async def test_a_wipeout_survives_save_and_reload(backend, tmp_path):
import ezdxf
created = await backend.entity_create_wipeout(SQUARE)
path = tmp_path / "wipeout.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(created["handle"])
assert reloaded is not None and reloaded.dxftype() == "WIPEOUT"
@pytest.mark.parametrize("bad", [[], [[0.0, 0.0]], [[0.0, 0.0], [1.0, 1.0]]])
async def test_a_wipeout_needs_at_least_three_points(backend, bad):
"""Two points enclose no area; a zero-area mask masks nothing."""
result = await backend.entity_create_wipeout(bad)
assert result["ok"] is False
# ── revision cloud ──────────────────────────────────────────────────────────
async def test_a_revision_cloud_is_a_bulged_polyline(backend):
"""The arcs are the cloud. A revcloud without bulges is a rectangle."""
result = await backend.entity_create_revcloud(SQUARE, segment_length=10.0)
assert result["ok"] is True
entity = backend._doc.entitydb.get(result["handle"])
assert entity.dxftype() == "LWPOLYLINE"
bulges = [point[4] for point in entity.get_points()]
assert all(abs(b) > 0.1 for b in bulges), "every segment must carry an arc"
assert result["segments"] == len(bulges)
async def test_the_cloud_is_recognisable_as_one(backend):
from ezdxf.revcloud import is_revcloud
result = await backend.entity_create_revcloud(SQUARE, segment_length=10.0)
assert is_revcloud(backend._doc.entitydb.get(result["handle"]))
async def test_a_segment_longer_than_the_path_is_refused(backend):
"""Otherwise the result is a plain rectangle reported as a cloud."""
result = await backend.entity_create_revcloud(SQUARE, segment_length=10_000.0)
assert result["ok"] is False
assert "segment_length" in result["error"]
@pytest.mark.parametrize("bad", [0.0, -5.0])
async def test_a_non_positive_segment_length_is_refused(backend, bad):
result = await backend.entity_create_revcloud(SQUARE, segment_length=bad)
assert result["ok"] is False
# ── MTEXT background mask ───────────────────────────────────────────────────
async def test_a_background_mask_round_trips(backend, tmp_path):
"""A mask that vanishes on save is worse than no mask."""
import ezdxf
mtext = await backend.entity_create_mtext("SECTION A-A", 10, 10)
result = await backend.text_set_background(mtext.handle, color=2, scale=1.5)
assert result["ok"] is True
path = tmp_path / "masked.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(mtext.handle)
assert reloaded.dxf.bg_fill == 1
assert reloaded.dxf.box_fill_scale == pytest.approx(1.5)
async def test_a_background_mask_can_be_turned_off_again(backend):
mtext = await backend.entity_create_mtext("X", 10, 10)
await backend.text_set_background(mtext.handle, color=2)
result = await backend.text_set_background(mtext.handle, enabled=False)
assert result["ok"] is True
assert result["enabled"] is False
assert backend._doc.entitydb.get(mtext.handle).dxf.get("bg_fill", 0) == 0
async def test_a_background_mask_on_plain_text_is_refused(backend):
"""Only MTEXT carries a background fill; TEXT silently would not."""
text = await backend.entity_create_text("PLAIN", 0, 0)
result = await backend.text_set_background(text.handle, color=2)
assert result["ok"] is False
assert "MTEXT" in result["error"]
@pytest.mark.parametrize("bad", [0.5, 0.0, -1.0])
async def test_a_mask_scale_below_one_is_refused(backend, bad):
"""A box smaller than the text is not a mask, it is a stripe."""
mtext = await backend.entity_create_mtext("X", 0, 0)
result = await backend.text_set_background(mtext.handle, color=2, scale=bad)
assert result["ok"] is False
# ── find / replace ──────────────────────────────────────────────────────────
async def _tagged_block(backend):
doc = backend._doc
block = doc.blocks.new("TAGGED")
block.add_attdef("PART", (0, 0), text="OLD")
ref = doc.modelspace().add_blockref("TAGGED", (0, 0))
ref.add_auto_attribs({"PART": "OLD"})
return ref
async def test_find_replace_covers_text_mtext_and_block_attributes(backend):
"""Silently skipping attributes inside blocks is this tool's honesty trap.
Four hits, not three: the ATTDEF in the block *definition* carries the
default value the next insert will use, so leaving it behind would fix the
drawing and reintroduce the old text on the next block reference.
"""
text = await backend.entity_create_text("OLD value", 0, 0)
mtext = await backend.entity_create_mtext("OLD value", 0, 10)
await _tagged_block(backend)
result = await backend.text_find_replace("OLD", "NEW")
assert result["ok"] is True
assert result["replaced"] == 4
changed = {row["type"] for row in result["entities"]}
assert changed == {"TEXT", "MTEXT", "ATTRIB", "ATTDEF"}
assert backend._doc.entitydb.get(text.handle).dxf.text == "NEW value"
assert backend._doc.entitydb.get(mtext.handle).text == "NEW value"
async def test_it_reports_which_types_it_searched(backend):
""" "0 replacements" and "I never looked there" are different answers."""
result = await backend.text_find_replace("NOTHING", "X")
assert result["ok"] is True
assert result["replaced"] == 0
assert set(result["searched_types"]) == {"TEXT", "MTEXT", "ATTRIB", "ATTDEF"}
assert "DIMENSION" in result["note"]
async def test_a_dry_run_changes_nothing(backend):
text = await backend.entity_create_text("OLD", 0, 0)
result = await backend.text_find_replace("OLD", "NEW", dry_run=True)
assert result["replaced"] == 1
assert result["dry_run"] is True
assert backend._doc.entitydb.get(text.handle).dxf.text == "OLD"
async def test_it_can_be_scoped_to_a_layer(backend):
await backend.entity_create_text("OLD", 0, 0, layer="TEXT")
other = await backend.entity_create_text("OLD", 0, 10, layer="DIM")
result = await backend.text_find_replace("OLD", "NEW", layer="TEXT")
assert result["replaced"] == 1
assert backend._doc.entitydb.get(other.handle).dxf.text == "OLD"
async def test_an_empty_search_string_is_refused(backend):
"""Replacing "" matches between every character and destroys the text."""
result = await backend.text_find_replace("", "X")
assert result["ok"] is False
async def test_case_sensitivity_is_the_callers_choice(backend):
text = await backend.entity_create_text("Old Value", 0, 0)
result = await backend.text_find_replace("old", "NEW", match_case=False)
assert result["replaced"] == 1
assert backend._doc.entitydb.get(text.handle).dxf.text == "NEW Value"