fix: three measurement defects the adversarial triage surfaced

**COM discarded areas it had already read.** `entity_measure` fell back to
rebuilding geometry from `.Coordinates` when `.Area` *or* `.Length` was
missing, coupling two independent property reads. HATCH, REGION and 3DSOLID
expose `.Area` and neither of the others, so the fallback fired, found no
vertices, and raised "AutoCAD reported neither an area nor usable vertices" --
while holding the area. Those are the exact types the README sells the COM
backend on. Now only a missing `.Area` triggers the fallback; an absent
perimeter is reported as `None` with `perimeter_exact: False` instead of being
invented, which also removes a `round(None, 6)` TypeError waiting for the first
type that answers `.Area` and nothing else.

**A plain square hatch reported itself self-intersecting.** `ezdxf.path
.flattening()` repeats the start point, and `is_self_intersecting` excluded
adjacent edges *by index*, so with the duplicate present the first and last
real edges were cross-tested and registered as crossing. Measured: a 40x40
SOLID hatch came back `area: 1600.0, self_intersecting: True`. A closed loop
now answers the same whether or not it carries its closing vertex, and a real
bowtie is still caught (`area: 0.0, self_intersecting: True`).

**The bowtie warning never fired on the live backend.** `self_intersecting` was
only computed inside the COM fallback, so an LWPOLYLINE bowtie -- which has
both `.Area` and `.Length` and never reaches it -- returned `area: 0.0,
self_intersecting: null`. The confident zero this field exists to flag, going
out unflagged. It is now computed on the normal path too.

Also corrects a docstring written earlier today in `contracts/analysis.py`
claiming the live backend "asks AutoCAD for `.Area`, which has already applied
the [hatch island] style". Whether ActiveX's hatch area applies the style is
not verified against a live seat, and the method could not measure a hatch at
all. It now says so.

The ActiveX member profiles behind the COM tests are assumptions, not facts,
and the tests say so; a live seat settles them. `.Circumference` for CIRCLE and
`.Perimeter` for REGION are deliberately NOT added here -- writing more members
from the reference is what produced four wrong beliefs in v1.4/v1.5.

1267 passed, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Umut CAN
2026-08-06 15:08:50 +03:00
co-authored by Claude Opus 5
parent 25f8737132
commit e24b46d6f0
6 changed files with 242 additions and 19 deletions
+130
View File
@@ -301,3 +301,133 @@ async def test_com_backend_connects():
assert backend.is_connected
await backend.disconnect()
assert not backend.is_connected
# ── entity_measure: a good .Area must not be thrown away ────────────────────
#
# The fallback fired when `.Area` OR `.Length` was missing, and then demanded
# `.Coordinates`, which only the polyline types carry. So HATCH, REGION and
# 3DSOLID — the very types the README sells the COM backend on — discarded a
# successfully-read area and raised "AutoCAD reported neither an area nor
# usable vertices", which was false: it had the area.
#
# These profiles are ASSUMPTIONS about the ActiveX member table, not facts, and
# they are what a live seat has to confirm. What they do test is our dispatch,
# which is where the defect is: given a member profile, does the code keep the
# number it already has?
def _measure_backend(entity):
"""A ComBackend whose HandleToObject returns exactly this entity."""
import backends.com_backend as cb
backend = ComBackend()
async def _run(func, *args, **kwargs): # bypass the STA executor
return func(*args, **kwargs)
backend._run = _run
doc = MagicMock()
doc.HandleToObject.return_value = entity
cb._acad_doc = lambda: doc
return backend
class _Entity:
"""An ActiveX entity that has only the members it is given."""
def __init__(self, object_name, **members):
self.ObjectName = object_name
for name, value in members.items():
setattr(self, name, value)
def __getattr__(self, name): # anything not set simply does not exist
raise AttributeError(name)
@pytest.mark.asyncio
async def test_a_hatch_with_an_area_and_no_length_keeps_the_area():
"""The red case: today this raises, holding a perfectly good 300.0."""
backend = _measure_backend(_Entity("AcDbHatch", Area=300.0))
result = await backend.entity_measure("2AF")
assert result["area"] == pytest.approx(300.0)
assert result["method"] == "activex_area"
@pytest.mark.asyncio
async def test_a_solid_without_a_perimeter_says_so_instead_of_inventing_one():
backend = _measure_backend(_Entity("AcDb3dSolid", Area=1250.0))
result = await backend.entity_measure("3B0")
assert result["area"] == pytest.approx(1250.0)
assert result["perimeter"] is None
assert result["perimeter_exact"] is False
@pytest.mark.asyncio
async def test_the_polyline_fallback_still_fires_when_the_area_is_the_missing_one():
"""The fallback exists for a reason and must keep working."""
entity = _Entity("AcDbPolyline", Coordinates=(0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0))
entity.GetBulge = lambda index: 0.0
entity.Closed = True
backend = _measure_backend(entity)
result = await backend.entity_measure("4C1")
assert result["method"] == "activex_fallback_analytic"
assert result["area"] == pytest.approx(100.0)
@pytest.mark.asyncio
async def test_the_refusal_is_only_used_when_there_is_genuinely_nothing():
"""The message names both halves, so it must only appear when both are true."""
backend = _measure_backend(_Entity("AcDbSpline"))
with pytest.raises(RuntimeError, match="neither an area nor usable vertices"):
await backend.entity_measure("5D2")
@pytest.mark.asyncio
async def test_a_line_is_still_refused_before_any_of_this():
backend = _measure_backend(_Entity("AcDbLine", Area=0.0))
with pytest.raises(RuntimeError, match="does not bound an area"):
await backend.entity_measure("6E3")
@pytest.mark.asyncio
async def test_a_live_bowtie_is_flagged_even_though_activex_answered():
"""`area: 0.0` with no warning is the exact failure this field prevents.
A bowtie LWPOLYLINE has both `.Area` (which AutoCAD returns as the
shoelace, cancelling the crossed lobes to ~0) and `.Length`, so it never
reached the fallback where `self_intersecting` used to be computed.
"""
entity = _Entity(
"AcDbPolyline",
Area=0.0,
Length=200.0,
Coordinates=(0.0, 0.0, 50.0, 50.0, 50.0, 0.0, 0.0, 50.0),
)
backend = _measure_backend(entity)
result = await backend.entity_measure("7F4")
assert result["method"] == "activex_area"
assert result["self_intersecting"] is True
@pytest.mark.asyncio
async def test_a_simple_polyline_is_not_flagged():
entity = _Entity(
"AcDbPolyline",
Area=2500.0,
Length=200.0,
Coordinates=(0.0, 0.0, 50.0, 0.0, 50.0, 50.0, 0.0, 50.0),
)
backend = _measure_backend(entity)
assert (await backend.entity_measure("8A5"))["self_intersecting"] is False