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>
275 lines
12 KiB
Python
275 lines
12 KiB
Python
"""T0.4 — coordinates that cross the MCP boundary are WCS, on both engines.
|
|
|
|
DXF does not store 2D geometry in world coordinates. A CIRCLE, ARC, LWPOLYLINE,
|
|
TEXT or INSERT lives in an Object Coordinate System derived from its ``extrusion``
|
|
vector by the Arbitrary Axis Algorithm. Mirroring flips the extrusion to
|
|
``(0, 0, -1)`` and leaves the stored x *unnegated*, so reading ``dxf.center``
|
|
raw returns a point the entity is not at.
|
|
|
|
The word "extrusion" appeared nowhere in this repository, so every such read was
|
|
wrong and silent. Measured: after the server's own ``entity_mirror`` across the Y
|
|
axis, a circle drawn at (30, 20) reported ``center [30.0, 20.0]`` while the *same
|
|
response's* ``bounding_box`` said ``min [-35, 15] / max [-25, 25]`` — the payload
|
|
contradicted itself by 60 mm, and ``point_from_snap``, ``entity_select_smart`` and
|
|
``entity_offset`` all inherited the error.
|
|
|
|
Two fences guard the obvious wrong fixes:
|
|
|
|
* ``test_wcs_entities_are_left_alone`` — LINE, MTEXT, ELLIPSE and SPLINE all carry
|
|
an ``extrusion`` attribute too, and are already correct because ezdxf makes
|
|
their ``ocs()`` a pass-through. Keying the fix off "has extrusion" breaks them.
|
|
* ``test_read_write_round_trip_is_a_no_op_on_a_mirrored_circle`` — the read and
|
|
write paths were *consistently* wrong, so normalising reads without the write
|
|
inverse would turn a harmless no-op into a real 60 mm teleport.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from backends.base import UnsupportedCapabilityError
|
|
from backends.com_backend import ComBackend
|
|
from backends.ezdxf_backend import EzdxfBackend
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
#: Mirror line = the Y axis, i.e. x -> -x. This is what produces extrusion (0,0,-1).
|
|
Y_AXIS = (0.0, 0.0, 0.0, 1.0)
|
|
|
|
|
|
def _assert_points(actual, expected) -> None:
|
|
"""pytest.approx refuses nested sequences, so compare point by point."""
|
|
assert len(actual) == len(expected), f"{actual} != {expected}"
|
|
for got, want in zip(actual, expected, strict=True):
|
|
assert got == pytest.approx(want, abs=1e-9), f"{actual} != {expected}"
|
|
|
|
|
|
def _bbox_centre(info) -> tuple[float, float]:
|
|
"""Centre of the entity's own bounding box — an independent oracle.
|
|
|
|
``ezdxf.bbox.extents`` flattens the entity through the same renderer AutoCAD
|
|
would, so it is WCS whatever the entity's frame is. It is the field that
|
|
exposed the contradiction, and it is not computed from the OCS attributes.
|
|
"""
|
|
box = info.properties["bounding_box"]
|
|
return (
|
|
(box["min"][0] + box["max"][0]) / 2.0,
|
|
(box["min"][1] + box["max"][1]) / 2.0,
|
|
)
|
|
|
|
|
|
# ── the fence: entities that were never wrong ───────────────────────────────
|
|
|
|
|
|
async def test_wcs_entities_are_left_alone(backend):
|
|
"""LINE/MTEXT/ELLIPSE/SPLINE carry extrusion but report WCS already."""
|
|
line = await backend.entity_create_line(10, 5, 30, 5)
|
|
ellipse = await backend.entity_create_ellipse(20, 40, 10, 5)
|
|
text = await backend.entity_create_mtext("hello", 25, 60, 2.5)
|
|
|
|
for created in (line, ellipse, text):
|
|
mirrored = await backend.entity_mirror(created.handle, *Y_AXIS)
|
|
info = await backend.entity_get(mirrored.handle)
|
|
cx, cy = _bbox_centre(info)
|
|
for key in ("start", "end", "center", "insertion"):
|
|
point = info.properties.get(key)
|
|
if point is None:
|
|
continue
|
|
assert point[0] < 0, f"{info.type}.{key} must land left of x=0 after mirroring"
|
|
if "center" in info.properties:
|
|
assert info.properties["center"] == pytest.approx([cx, cy], abs=1e-6)
|
|
|
|
|
|
# ── the defect ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_entity_get_center_agrees_with_its_own_bounding_box(backend):
|
|
"""A response that contradicts itself is the clearest possible proof."""
|
|
circle = await backend.entity_create_circle(30, 20, 5)
|
|
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
|
|
|
info = await backend.entity_get(mirrored.handle)
|
|
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
|
|
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
|
|
assert info.properties["radius"] == pytest.approx(5.0)
|
|
|
|
|
|
async def test_mirrored_arc_reports_wcs_centre_and_sweep(backend):
|
|
"""A left-handed frame reverses the sweep, so start/end also swap."""
|
|
arc = await backend.entity_create_arc(30, 20, 5, 0, 90)
|
|
mirrored = await backend.entity_mirror(arc.handle, *Y_AXIS)
|
|
|
|
info = await backend.entity_get(mirrored.handle)
|
|
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
|
|
|
|
# Mirroring the first quadrant across x=0 gives the second: the bbox says the
|
|
# arc occupies x in [-35, -30], y in [20, 25], i.e. 90 deg -> 180 deg.
|
|
assert info.properties["start_angle"] == pytest.approx(90.0, abs=1e-6)
|
|
assert info.properties["end_angle"] == pytest.approx(180.0, abs=1e-6)
|
|
# Length is frame-invariant and must not change.
|
|
assert info.properties["length"] == pytest.approx(5 * 3.141592653589793 / 2, rel=1e-9)
|
|
|
|
|
|
async def test_mirrored_polyline_points_are_wcs(backend):
|
|
poly = await backend.entity_create_polyline([[10, 0], [20, 0], [20, 10]], closed=False)
|
|
mirrored = await backend.entity_mirror(poly.handle, *Y_AXIS)
|
|
|
|
info = await backend.entity_get(mirrored.handle)
|
|
_assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]])
|
|
|
|
|
|
async def test_mirrored_text_insertion_is_wcs(backend):
|
|
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
|
|
mirrored = await backend.entity_mirror(text.handle, *Y_AXIS)
|
|
|
|
info = await backend.entity_get(mirrored.handle)
|
|
assert info.properties["insertion"] == pytest.approx([-40.0, 15.0])
|
|
|
|
|
|
# ── the trap: reads must not be normalised without the write inverse ────────
|
|
|
|
|
|
async def test_read_write_round_trip_is_a_no_op_on_a_mirrored_circle(backend):
|
|
"""Read the centre, write it straight back: the circle must not move.
|
|
|
|
Before the fix this passed for the wrong reason — read and write were
|
|
*consistently* wrong. It is here so that fixing only the read is caught.
|
|
"""
|
|
circle = await backend.entity_create_circle(30, 20, 5)
|
|
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
|
|
|
before = await backend.entity_get(mirrored.handle)
|
|
centre = before.properties["center"]
|
|
await backend.entity_edit_geometry(mirrored.handle, cx=centre[0], cy=centre[1])
|
|
after = await backend.entity_get(mirrored.handle)
|
|
|
|
assert after.properties["center"] == pytest.approx(centre)
|
|
assert after.properties["bounding_box"] == before.properties["bounding_box"]
|
|
|
|
|
|
async def test_entity_edit_geometry_moves_a_mirrored_circle_where_asked(backend):
|
|
circle = await backend.entity_create_circle(30, 20, 5)
|
|
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
|
|
|
await backend.entity_edit_geometry(mirrored.handle, cx=-10.0, cy=40.0)
|
|
|
|
info = await backend.entity_get(mirrored.handle)
|
|
assert info.properties["center"] == pytest.approx([-10.0, 40.0])
|
|
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
|
|
|
|
|
|
async def test_entity_offset_on_a_mirrored_circle_is_concentric_and_shrinks(backend):
|
|
"""The offset used to land 60 mm away *and* grow instead of shrink."""
|
|
circle = await backend.entity_create_circle(30, 20, 10)
|
|
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
|
|
|
inner = await backend.entity_offset(mirrored.handle, 2.0, side_x=-30.0, side_y=20.0)
|
|
|
|
info = await backend.entity_get(inner.handle)
|
|
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
|
|
assert info.properties["radius"] == pytest.approx(8.0), "offset toward the centre shrinks"
|
|
|
|
|
|
# ── tilted planes: the one case 2D xy cannot address ────────────────────────
|
|
|
|
|
|
def _tilt(backend, handle, normal=(0.0, 0.6, 0.8)) -> None:
|
|
"""Put an entity in a plane that is not parallel to WCS XY."""
|
|
backend._doc.entitydb.get(handle).dxf.extrusion = normal
|
|
|
|
|
|
async def test_a_tilted_entity_omits_what_xy_cannot_express(backend):
|
|
"""A tilted circle projects to an ellipse, so `radius` has no WCS-XY meaning.
|
|
|
|
Omitting the field is silence; reporting 5.0 for a footprint of 5.0 x 3.99
|
|
would be a fresh silent wrong number of exactly the kind this release exists
|
|
to remove.
|
|
"""
|
|
circle = await backend.entity_create_circle(30, 20, 5)
|
|
_tilt(backend, circle.handle)
|
|
|
|
info = await backend.entity_get(circle.handle)
|
|
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
|
|
assert "plane_normal" in info.properties
|
|
assert info.properties["plane_normal"] == pytest.approx([0.0, 0.6, 0.8])
|
|
assert "radius" not in info.properties
|
|
|
|
|
|
async def test_a_tilted_arc_omits_its_angles(backend):
|
|
arc = await backend.entity_create_arc(30, 20, 5, 0, 90)
|
|
_tilt(backend, arc.handle)
|
|
|
|
info = await backend.entity_get(arc.handle)
|
|
assert "start_angle" not in info.properties
|
|
assert "end_angle" not in info.properties
|
|
assert "plane_normal" in info.properties
|
|
|
|
|
|
async def test_entity_edit_geometry_refuses_a_tilted_plane_with_capability_shape(backend):
|
|
circle = await backend.entity_create_circle(30, 20, 5)
|
|
_tilt(backend, circle.handle)
|
|
|
|
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
|
await backend.entity_edit_geometry(circle.handle, cx=1.0, cy=2.0)
|
|
|
|
assert excinfo.value.capability == "ocs_tilted_plane"
|
|
payload = excinfo.value.to_dict()
|
|
assert payload["ok"] is False
|
|
assert "entity_move" in payload["error"], "a refusal must name the way forward"
|
|
|
|
|
|
# ── the boundary is pre-checkable ───────────────────────────────────────────
|
|
|
|
|
|
async def test_ocs_capability_keys_are_declared_on_both_backends():
|
|
for features in (
|
|
EzdxfBackend().capabilities().features,
|
|
ComBackend().capabilities().features,
|
|
):
|
|
assert features["ocs_normalized"].supported is True
|
|
assert features["ocs_normalized"].reason, "the residual must be named, not implied"
|
|
assert features["ocs_tilted_plane"].supported is False
|
|
|
|
|
|
# ── COM: the one genuinely wrong read on that engine ────────────────────────
|
|
|
|
|
|
def _fake_lwpolyline(coords, normal):
|
|
"""The minimum surface ``_entity_info`` touches for a lightweight polyline."""
|
|
import types
|
|
|
|
return types.SimpleNamespace(
|
|
ObjectName="AcDbLWPolyline",
|
|
Coordinates=coords,
|
|
Normal=normal,
|
|
Elevation=0.0,
|
|
Handle="2F",
|
|
Layer="0",
|
|
Color=256,
|
|
Linetype="ByLayer",
|
|
Visible=True,
|
|
Closed=False,
|
|
Length=30.0,
|
|
GetBoundingBox=lambda: ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0)),
|
|
)
|
|
|
|
|
|
async def test_com_lwpolyline_coordinates_are_translated_to_wcs():
|
|
"""ActiveX returns WCS for everything *except* polyline Coordinates (OCS).
|
|
|
|
Unit-tested against a fake COM object: this machine has a live AutoCAD and
|
|
connecting to it is forbidden, so this path ships verified only at this
|
|
level — the CHANGELOG says so.
|
|
"""
|
|
from backends.com_backend import _entity_info
|
|
|
|
info = _entity_info(_fake_lwpolyline((10.0, 0.0, 20.0, 0.0, 20.0, 10.0), (0.0, 0.0, -1.0)))
|
|
_assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]])
|
|
|
|
|
|
async def test_com_lwpolyline_with_identity_normal_is_untouched():
|
|
from backends.com_backend import _entity_info
|
|
|
|
info = _entity_info(_fake_lwpolyline((10.0, 0.0, 20.0, 0.0), (0.0, 0.0, 1.0)))
|
|
_assert_points(info.properties["points"], [[10, 0], [20, 0]])
|