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
292 lines
13 KiB
Python
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]])
|