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

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]])