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>
326 lines
15 KiB
Python
326 lines
15 KiB
Python
"""F2 — LIST: the raw DXF attribute set for one handle.
|
|
|
|
`entity_get` reports a *curated* view: the handful of derived fields the drafting
|
|
tools need, already flattened to WCS xy. `entity_measure` reports lengths and
|
|
areas. Neither answers the question AutoCAD's LIST answers — "what is actually
|
|
stored on this object?" — and that is the only reason this tool exists. It must
|
|
not restate the other two.
|
|
|
|
Four measured facts shape every test below.
|
|
|
|
* **The dump is not JSON-serialisable as it comes out of ezdxf.**
|
|
`entity.dxfattribs()` returns `Vec3` for `start` / `end` / `insert` / `center`.
|
|
Measured: `json.dumps` raises ``Object of type Vec3 is not JSON serializable``
|
|
for LINE, CIRCLE, TEXT and INSERT. A tool that hands that dict back either dies
|
|
at the wire boundary or gets silently coerced into `"Vec3(0.0, 0.0, 0.0)"` —
|
|
a string where the caller expected a point. So `json.dumps(result)` is asserted
|
|
outright, for all five point-carrying types, and the values are asserted to
|
|
still be *numbers* afterwards: stringifying the Vec3 would satisfy `json.dumps`
|
|
and destroy the payload.
|
|
|
|
* **The dump is WCS, like everything else crossing a tool boundary.**
|
|
A mirrored CIRCLE stores `center = (30, 20)` with `extrusion = (0, 0, -1)`;
|
|
it is drawn at (-30, 20), which is what `entity_get` reports. A raw
|
|
`dxfattribs()` echo therefore contradicts `entity_get` in the same payload by
|
|
60 mm — the exact defect T0.4 was opened to remove. The dump still reports the
|
|
`extrusion` it translated *from*, because a caller who cannot see the frame
|
|
cannot tell a translated point from an untranslated one.
|
|
|
|
* **`dxfattribs()` on an LWPOLYLINE contains no geometry at all.**
|
|
Measured: the keys are `flags`, `handle`, `layer`, `owner` — the vertices live
|
|
in `get_points()`, not in the attribute dict. A tool that dumps `dxfattribs()`
|
|
and stops reports a polyline with no points and calls it a full property dump.
|
|
The vertices must carry their **bulge** too: `entity_get` gives bare `[x, y]`
|
|
pairs, and reconstructing a bulged outline from those loses 28.2% of the area.
|
|
|
|
* **Only stored attributes are reported.** `thickness` is absent from a fresh
|
|
LINE and appears once set. Padding the dump with schema defaults would make an
|
|
explicitly-set 0.0 indistinguishable from "never touched", which is the one
|
|
question a raw attribute dump exists to answer.
|
|
|
|
The refusal: an unknown or deleted handle raises `RuntimeError`, matching
|
|
`entity_get`. Returning `{}` for a handle that is not there would report "this
|
|
entity has no properties" for an entity that has no existence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
#: Mirror line = the Y axis (x -> -x). This is what produces extrusion (0, 0, -1).
|
|
Y_AXIS = (0.0, 0.0, 0.0, 1.0)
|
|
|
|
|
|
def _numbers(value):
|
|
"""Assert `value` is a real numeric sequence and return its x, y.
|
|
|
|
Accepts a 2- or 3-component point: the arity of the dump is the tool
|
|
author's call, but "it is a sequence of numbers" is not. `bool` is excluded
|
|
because `isinstance(True, int)` would otherwise wave a coerced flag through.
|
|
"""
|
|
assert isinstance(value, (list, tuple)), f"expected a point, got {value!r}"
|
|
assert len(value) in (2, 3), f"expected a 2D or 3D point, got {value!r}"
|
|
for component in value:
|
|
assert isinstance(component, (int, float)) and not isinstance(component, bool), (
|
|
f"point component {component!r} is not a number"
|
|
)
|
|
return float(value[0]), float(value[1])
|
|
|
|
|
|
async def _one_of_each(backend):
|
|
"""A LINE, LWPOLYLINE, INSERT, TEXT and CIRCLE — every Vec3-carrying type."""
|
|
backend._doc.blocks.new("BOLT").add_circle((0.0, 0.0), 2.0)
|
|
return {
|
|
"LINE": (await backend.entity_create_line(0, 0, 100, 50)).handle,
|
|
"LWPOLYLINE": (
|
|
await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
|
|
).handle,
|
|
"INSERT": (await backend.block_insert("BOLT", 10, 20, scale_x=2.0, scale_y=3.0)).handle,
|
|
"TEXT": (await backend.entity_create_text("PART A", 40, 15, 3.0)).handle,
|
|
"CIRCLE": (await backend.entity_create_circle(30, 20, 5)).handle,
|
|
}
|
|
|
|
|
|
# ── the wire boundary ───────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize("dxftype", ["LINE", "LWPOLYLINE", "INSERT", "TEXT", "CIRCLE"])
|
|
async def test_the_dump_survives_json_encoding(backend, dxftype):
|
|
"""Measured: raw `dxfattribs()` raises `Object of type Vec3 is not JSON serializable`."""
|
|
handles = await _one_of_each(backend)
|
|
|
|
result = await backend.analysis_list_properties(handles[dxftype])
|
|
|
|
encoded = json.dumps(result)
|
|
assert json.loads(encoded) == result, "the payload changed shape on the way through JSON"
|
|
|
|
|
|
async def test_a_lines_endpoints_are_numbers_not_stringified_vectors(backend):
|
|
"""`str(Vec3(...))` would satisfy json.dumps and hand the caller a string."""
|
|
line = await backend.entity_create_line(0, 0, 100, 50)
|
|
|
|
dump = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
|
|
|
|
assert _numbers(dump["start"]) == pytest.approx((0.0, 0.0))
|
|
assert _numbers(dump["end"]) == pytest.approx((100.0, 50.0))
|
|
|
|
|
|
async def test_a_texts_insertion_point_is_numbers_not_a_stringified_vector(backend):
|
|
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
|
|
|
|
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
|
|
|
|
assert _numbers(dump["insert"]) == pytest.approx((40.0, 15.0))
|
|
assert dump["text"] == "PART A"
|
|
assert dump["height"] == pytest.approx(3.0)
|
|
|
|
|
|
async def test_an_inserts_insertion_point_is_numbers_not_a_stringified_vector(backend):
|
|
backend._doc.blocks.new("BOLT").add_circle((0.0, 0.0), 2.0)
|
|
insert = await backend.block_insert("BOLT", 10, 20, scale_x=2.0, scale_y=3.0)
|
|
|
|
dump = (await backend.analysis_list_properties(insert.handle))["dxf_attributes"]
|
|
|
|
assert _numbers(dump["insert"]) == pytest.approx((10.0, 20.0))
|
|
assert dump["name"] == "BOLT"
|
|
assert dump["xscale"] == pytest.approx(2.0)
|
|
assert dump["yscale"] == pytest.approx(3.0)
|
|
|
|
|
|
# ── coordinates are WCS ─────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_a_mirrored_circles_centre_is_reported_in_wcs(backend):
|
|
"""Stored (30, 20) with extrusion -Z; drawn at (-30, 20). The dump says drawn.
|
|
|
|
Echoing `dxfattribs()` puts a 60 mm contradiction inside one payload, because
|
|
`entity_get` on the same handle reports (-30, 20).
|
|
"""
|
|
circle = await backend.entity_create_circle(30, 20, 5)
|
|
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
|
assert backend._doc.entitydb.get(mirrored.handle).dxf.center.x == pytest.approx(30.0), (
|
|
"precondition: the DXF really does store the un-negated x"
|
|
)
|
|
|
|
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
|
|
|
|
assert _numbers(dump["center"]) == pytest.approx((-30.0, 20.0))
|
|
info = await backend.entity_get(mirrored.handle)
|
|
assert _numbers(dump["center"]) == pytest.approx(tuple(info.properties["center"]))
|
|
|
|
|
|
async def test_the_dump_still_names_the_frame_it_translated_from(backend):
|
|
"""Drop the extrusion and a caller cannot tell a translated point from a raw one."""
|
|
circle = await backend.entity_create_circle(30, 20, 5)
|
|
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
|
|
|
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
|
|
|
|
assert list(dump["extrusion"]) == pytest.approx([0.0, 0.0, -1.0])
|
|
assert dump["radius"] == pytest.approx(5.0), "radius is frame-invariant in a flat frame"
|
|
|
|
|
|
# ── what entity_get deliberately does not carry ─────────────────────────────
|
|
|
|
|
|
async def test_a_polylines_vertices_are_in_the_dump(backend):
|
|
"""Measured: `dxfattribs()` for an LWPOLYLINE is flags/handle/layer/owner only.
|
|
|
|
Dumping it verbatim describes a polyline without saying where it is.
|
|
"""
|
|
poly = await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
|
|
assert "points" not in backend._doc.entitydb.get(poly.handle).dxfattribs(), (
|
|
"precondition: ezdxf keeps the vertices out of the attribute dict"
|
|
)
|
|
|
|
dump = (await backend.analysis_list_properties(poly.handle))["dxf_attributes"]
|
|
|
|
assert [_numbers(pt) for pt in dump["points"]] == pytest.approx(
|
|
[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]
|
|
)
|
|
|
|
|
|
async def test_a_polyline_vertex_carries_its_bulge(backend):
|
|
"""`entity_get` gives bare [x, y]; rebuilding a bulged outline from those
|
|
loses 28.2% of the area. The full dump is where the bulge has to live."""
|
|
poly = await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
|
|
backend._doc.entitydb.get(poly.handle).set_points(
|
|
[(0, 0, 0.0), (10, 0, 0.5), (10, 10, 0.0)], format="xyb"
|
|
)
|
|
|
|
dump = (await backend.analysis_list_properties(poly.handle))["dxf_attributes"]
|
|
|
|
bulges = [pt[2] for pt in dump["points"]]
|
|
assert len(bulges) == 3, "every vertex reports a bulge, including the straight ones"
|
|
assert bulges == pytest.approx([0.0, 0.5, 0.0])
|
|
|
|
|
|
async def test_text_style_and_obliquity_reach_the_caller(backend):
|
|
"""Neither appears anywhere in `entity_get`, and both change what is plotted."""
|
|
backend._doc.styles.new("ISO", dxfattribs={"font": "isocp.shx"})
|
|
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
|
|
entity = backend._doc.entitydb.get(text.handle)
|
|
entity.dxf.style = "ISO"
|
|
entity.dxf.oblique = 15.0
|
|
|
|
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
|
|
|
|
assert dump["style"] == "ISO"
|
|
assert dump["oblique"] == pytest.approx(15.0)
|
|
info = await backend.entity_get(text.handle)
|
|
assert "style" not in info.properties, "precondition: entity_get is the curated view"
|
|
|
|
|
|
async def test_the_dump_reports_what_is_stored_not_schema_defaults(backend):
|
|
"""A dump padded with defaults cannot answer "did the drafter set this?".
|
|
|
|
That is the only question a raw attribute dump exists to answer, so an
|
|
unset `thickness` must be absent rather than reported as 0.0.
|
|
"""
|
|
line = await backend.entity_create_line(0, 0, 100, 50)
|
|
|
|
before = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
|
|
assert "thickness" not in before
|
|
assert "ltscale" not in before
|
|
|
|
entity = backend._doc.entitydb.get(line.handle)
|
|
entity.dxf.thickness = 2.0
|
|
entity.dxf.ltscale = 0.5
|
|
after = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
|
|
|
|
assert after["thickness"] == pytest.approx(2.0)
|
|
assert after["ltscale"] == pytest.approx(0.5)
|
|
|
|
|
|
# ── the top level ───────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_the_type_and_layer_sit_at_the_top_level(backend):
|
|
"""A caller asking "what is this?" should not have to dig into the dump."""
|
|
circle = await backend.entity_create_circle(30, 20, 5, layer="GEOMETRY")
|
|
|
|
result = await backend.analysis_list_properties(circle.handle)
|
|
|
|
assert result["ok"] is True
|
|
assert result["handle"] == circle.handle
|
|
assert result["type"] == "CIRCLE"
|
|
assert result["layer"] == "GEOMETRY"
|
|
assert result["dxf_attributes"]["layer"] == "GEOMETRY", "the two must not disagree"
|
|
|
|
|
|
async def test_the_summary_does_not_contradict_entity_get(backend):
|
|
"""`properties` restates the curated view, so it must at least agree with it.
|
|
|
|
This is the test that argues for deleting the key: a second copy of
|
|
`entity_get`'s output can only ever be identical or wrong.
|
|
"""
|
|
circle = await backend.entity_create_circle(30, 20, 5)
|
|
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
|
|
|
result = await backend.analysis_list_properties(mirrored.handle)
|
|
|
|
info = await backend.entity_get(mirrored.handle)
|
|
assert result["properties"]["center"] == pytest.approx(info.properties["center"])
|
|
|
|
|
|
# ── the refusal ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_an_unknown_handle_is_refused_not_answered_with_an_empty_dump(backend):
|
|
"""`{}` would read as "this entity has no properties" for a nonexistent entity."""
|
|
with pytest.raises(RuntimeError, match="FFFFFF"):
|
|
await backend.analysis_list_properties("FFFFFF")
|
|
|
|
|
|
async def test_a_deleted_handle_is_refused_too(backend):
|
|
"""`entitydb.get` does not filter destroyed entities, so the guard must."""
|
|
line = await backend.entity_create_line(0, 0, 100, 50)
|
|
await backend.entity_delete(line.handle)
|
|
|
|
with pytest.raises(RuntimeError, match=line.handle):
|
|
await backend.analysis_list_properties(line.handle)
|
|
|
|
|
|
# ── round trip ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_the_dump_describes_what_actually_lands_in_the_file(backend, tmp_path):
|
|
"""The dump is a translation of the file, not a snapshot of in-memory state.
|
|
|
|
The reloaded DXF still stores the OCS centre; the dump reported the WCS one.
|
|
Both readings of the same circle must remain consistent after a save.
|
|
"""
|
|
import ezdxf
|
|
|
|
circle = await backend.entity_create_circle(30, 20, 5)
|
|
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
|
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
|
|
|
|
path = tmp_path / "listed.dxf"
|
|
await backend.drawing_save_as(str(path))
|
|
reloaded = ezdxf.readfile(str(path)).entitydb.get(mirrored.handle)
|
|
|
|
assert reloaded.dxf.center.x == pytest.approx(30.0), "the file keeps the OCS x"
|
|
assert list(reloaded.dxf.extrusion) == pytest.approx(list(dump["extrusion"]))
|
|
assert reloaded.dxf.radius == pytest.approx(dump["radius"])
|
|
assert _numbers(dump["center"]) == pytest.approx((-reloaded.dxf.center.x, 20.0))
|
|
|
|
|
|
async def test_a_texts_dump_round_trips_through_a_saved_file(backend, tmp_path):
|
|
import ezdxf
|
|
|
|
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
|
|
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
|
|
|
|
path = tmp_path / "listed_text.dxf"
|
|
await backend.drawing_save_as(str(path))
|
|
reloaded = ezdxf.readfile(str(path)).entitydb.get(text.handle)
|
|
|
|
assert reloaded.dxf.text == dump["text"]
|
|
assert reloaded.dxf.height == pytest.approx(dump["height"])
|
|
assert _numbers(dump["insert"]) == pytest.approx((reloaded.dxf.insert.x, reloaded.dxf.insert.y))
|