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>
221 lines
9.0 KiB
Python
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"
|