Files
autocad-mcp-pro/tests/test_ocs_extrusion.py
T
Umut CANandClaude Opus 5 8b6f355c3d fix(task-14): polyline bulges leave the backends in WCS, so a mirrored arc reads on its true side
The per-vertex `bulges` list was handed out in the entity's own frame next
to `points` that had been translated to WCS. A mirrored LWPOLYLINE
(extrusion / Normal -Z, what `entity_mirror` produces) is a reflection,
which reverses every arc's sense, so the reader tested endpoints against
the arc's mirror image: a line ending on the true apex dangled, a line
ending on empty paper made a junction, and `length` (frame-invariant)
said nothing. Both engines had it — ezdxf emitted `get_points("xyb")`
raw, COM returned `GetBulge` raw after reflecting `Coordinates`.

`ocs.wcs_bulge(extrusion, bulge)` now negates the sign for a left-handed
frame (the same rule `wcs_arc_angles` applies to an ARC's endpoints) and
both `_entity_info` paths route bulges through it whenever they translate
the points. Fenced by an end-to-end mirrored-pipe graph test, the COM
`_entity_info` fake with Normal -Z, and a boundary-level OCS test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 00:44:34 +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]])