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
+29 -4
View File
@@ -3197,7 +3197,14 @@ class ComBackend(AutoCADBackend):
method = "activex_area"
closed = True
self_intersecting = None
if area is None or perimeter is None:
# Only the AREA decides whether we need the fallback. This used to
# read `area is None or perimeter is None`, which coupled two
# independent property reads: HATCH, REGION and 3DSOLID expose
# `.Area` but no `.Length`, so a perfectly good area was thrown
# away, the fallback demanded a `.Coordinates` only the polyline
# types carry, and the call died claiming AutoCAD "reported neither
# an area nor usable vertices" — while holding the area.
if area is None:
# Rebuild from the polyline's own vertices and run the SAME
# shared maths the headless engine uses, so a missing ActiveX
# property can never make this backend the less accurate one.
@@ -3214,15 +3221,33 @@ class ComBackend(AutoCADBackend):
)
closed = bool(getattr(ent, "Closed", True))
fallback_area, fallback_perimeter = polygon_area_perimeter(vertices, closed)
area = fallback_area if area is None else area
area = fallback_area
perimeter = fallback_perimeter if perimeter is None else perimeter
self_intersecting = is_self_intersecting([(v[0], v[1]) for v in vertices])
elif self_intersecting is None:
# The bowtie warning was only ever computed inside the fallback,
# so a live LWPOLYLINE bowtie — which has both `.Area` and
# `.Length` and therefore never reaches it — came back
# `area: 0.0, self_intersecting: null`. That is precisely the
# confident-zero this field exists to flag, going out unflagged.
coords = list(getattr(ent, "Coordinates", ()) or ())
if len(coords) >= 6:
self_intersecting = is_self_intersecting(
[
(float(coords[i]), float(coords[i + 1]))
for i in range(0, len(coords) - 1, 2)
]
)
# A perimeter we do not have is reported as absent, not invented.
# `round(None, 6)` was also a TypeError waiting for the first type
# that answers `.Area` and nothing else.
return {
"handle": str(handle),
"type": obj_name,
"area": round(float(area), 6),
"perimeter": round(float(perimeter), 6),
"perimeter": None if perimeter is None else round(float(perimeter), 6),
"closed": closed,
"assumed_closed": not closed,
"method": method,
@@ -3230,7 +3255,7 @@ class ComBackend(AutoCADBackend):
"flatten_tolerance": None,
"backend": "com",
"self_intersecting": self_intersecting,
"perimeter_exact": True,
"perimeter_exact": perimeter is not None,
"loop_count": 1,
}
+9 -2
View File
@@ -117,8 +117,15 @@ class AnalysisContract(ABC):
islands inside them — and the headless engine adds ``hatch_style``
(``normal``/``outer``/``ignore``) so the number can be read against the
drawing's own island setting. The live backend does not carry that key:
it asks AutoCAD for ``.Area``, which has already applied the style, so
there is no house rule left to disclose.
it reads ActiveX ``.Area``, and whether that number already has the
island style applied is **not verified against a live seat**. Until it
is, do not read a COM hatch area as equivalent to the headless one.
``perimeter`` is ``None`` where ActiveX exposes no length for the type
(REGION and 3DSOLID among them), and ``perimeter_exact`` is then False.
A missing perimeter is not a reason to discard an area that was read
successfully — doing exactly that is what made this method raise on
every ACIS type it advertised.
``exact`` and
``flatten_tolerance`` are the accuracy disclosure: when one engine can
+28 -13
View File
@@ -931,20 +931,31 @@ class EzdxfBackend(AutoCADBackend):
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
lock = self._lock
# One `asyncio.timeout_at` for both phases rather than two
# `asyncio.wait_for`s. `wait_for` wraps its awaitable in a Task, so the
# old shape cost two extra Tasks and two timer handles on *every* call
# — 4,000 of each to draw 2,000 lines, which is where the throughput
# this release lost was going. `acquired` keeps the two failure modes
# distinguishable, which is the whole reason they were separate.
acquired = False
call: asyncio.Future | None = None
try:
await asyncio.wait_for(lock.acquire(), timeout)
async with asyncio.timeout_at(deadline):
await lock.acquire()
acquired = True
call = asyncio.ensure_future(asyncio.to_thread(func, *args, **kwargs))
result = await call
except TimeoutError as exc:
raise self._busy_error(timeout) from exc
call = asyncio.ensure_future(asyncio.to_thread(func, *args, **kwargs))
try:
result = await asyncio.wait_for(call, max(deadline - loop.time(), 0.0))
except TimeoutError as exc:
if not call.cancelled():
# The call itself raised TimeoutError (an OSError subclass, and
# the same type wait_for raises) rather than overrunning the
# deadline. That is an ordinary failure: release the lock and
# let it through unchanged instead of blaming the deadline.
if not acquired:
# Timed out queueing behind someone else's live call. That lock
# is held by a call that will release it normally; do not
# abandon it.
raise self._busy_error(timeout) from exc
if call is None or not call.cancelled():
# The call itself raised TimeoutError (an OSError subclass,
# and the same type the deadline raises) rather than overrunning
# the deadline. That is an ordinary failure: release the lock
# and let it through unchanged instead of blaming the deadline.
lock.release()
raise
if self._lock is lock:
@@ -957,7 +968,11 @@ class EzdxfBackend(AutoCADBackend):
)
raise self._timeout_error(func, timeout) from exc
except BaseException:
lock.release()
# Only ours to release if we ever took it — cancellation can land
# while still queueing, and releasing someone else's lock there
# would hand the document to two callers at once.
if acquired:
lock.release()
raise
lock.release()
return result