Files
autocad-mcp-pro/tests/test_ocs_extrusion.py
Umut CANandClaude Opus 5 b54271f4ac fix(com): four live defects the settings smoke measured on AutoCAD 2026 — rejected calls after a document switch, typelib spelling of color/Lineweight, narrowed layer-state dictionary, layer 0 Freeze put
Measured 2026-09-22 by scripts/smoke_settings_com.py --build-dwt against the
running AutoCAD 2026 (25.1s), each on a scratch Documents.Add() document:

1. RPC_E_CALL_REJECTED (-2147418111) on Documents.Count straight after
   Documents.Open, after Close(False) inside document_close, and in
   _ensure_document_state: AutoCAD's own message filter refuses incoming
   calls while it is still switching documents. pywin32 exposes no
   CoRegisterMessageFilter, so _wait_out_rejected_call waits the window out
   for read-only callables (0.25 s -> 1 s pauses, 20 s budget, inside
   COM_CALL_TIMEOUT) at those sites and after Documents.Add/Open; any other
   com_error is not retried.
2. layer_list raised "'IAcadLayer' object has no attribute 'Color'. Did you
   mean: 'color'?": acax25enu.tlb spells IAcadEntity/IAcadLayer `color` and
   `Lineweight`. Late-bound dispatch resolved .Color/.LineWeight
   case-insensitively; with a makepy cache present (this machine's gen_py was
   regenerated on 2026-09-17, and every object a method returns is then a
   generated wrapper) the lookup is exact. All 17 sites now use the typelib
   spelling, which works on both wrappers; the fakes model the measured names.
3. layer_state_restore raised "'IAcadObject' object has no attribute
   'Count'": Dictionaries.Item and IAcadDictionary.Item are declared
   IAcadObject* (the narrowing _com_unnarrow already handles for MLEADER
   styles). The save path went through the typed Dictionaries.Add, so only
   the reopen paths failed. Both now un-narrow; the fakes narrow like pywin32.
4. The Freeze put on layer 0 raises 'Invalid layer' whatever the value, even
   False while thawed (color/Linetype/Lineweight/Plottable/LayerOn/Lock/
   ActiveLayer all fine on it), so a state saved with 0 current restored with
   two warnings and 0 unapplied. Freeze is written only when it changes the
   layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 13:07:31 +03:00

292 lines
13 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_polyline_bulges_are_wcs(backend):
"""A reflection reverses an arc's sense, so the bulge sign flips with the
points — otherwise ``points`` and ``bulges`` describe two different arcs
and, ``length`` being frame-invariant, nothing in the payload says so."""
msp = backend._doc.modelspace()
poly = msp.add_lwpolyline([(10, 0, 1), (20, 0, 0), (20, 10, 0)], format="xyb")
mirrored = await backend.entity_mirror(poly.dxf.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
_assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]])
# Bulge +1 bowed to -y from (10,0) to (20,0); mirrored across x=0 it runs
# (-10,0) to (-20,0) and still bows to -y, which in that direction is CW.
assert info.properties["bulges"] == pytest.approx([-1.0, 0.0, 0.0])
assert info.properties["bounding_box"]["min"][1] == pytest.approx(-5.0, abs=1e-9)
assert info.properties["length"] == pytest.approx(5 * 3.141592653589793 + 10, abs=1e-6)
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]])